Skip to content

fix(insert-event): guard against empty INSERT RETURNING#780

Merged
buremba merged 2 commits into
mainfrom
fix/insert-event-empty-returning
May 17, 2026
Merged

fix(insert-event): guard against empty INSERT RETURNING#780
buremba merged 2 commits into
mainfrom
fix/insert-event-empty-returning

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 17, 2026

Summary

`insertEvent` assumed `INSERT ... RETURNING` always yields a row, then dereferenced `result[0].id`. When the result came back empty (exposed by the menu bar's no-auth sync round-trip completing for the first time in PR #779) the call crashed with the cryptic `TypeError: Cannot read properties of undefined (reading 'id')` and the worker stream handler returned 500.

This adds two defensive guards in `packages/server/src/utils/insert-event.ts`:

  1. Main INSERT path — treat `result[0]` as possibly undefined, log connector/feed/origin context, throw a real error explaining the row wasn't persisted. Callers (worker `streamContent`) catch the throw and surface a useful message instead of the stack-trace blob.

  2. Conflict-update early-return — `existingRows[0]` could also be undefined if the found row was deleted/tombstoned between `findCurrentEventByOrigin` and the subsequent reread (race). Fall through to the fresh-insert path instead of crashing.

Root cause of the empty result

Still under investigation. Most likely culprit: PGlite quirk with the new `search_tsv tsvector GENERATED ALWAYS AS (...) STORED` column from PR #765, or a transient state during bootstrap-org/connector wire-up. The right behavior either way is to fail loud with context rather than silently crash on `undefined.id`.

Test plan

  • Fresh `LOBU_DATA_DIR` + Mac menu bar Start → worker stream calls return 200 (verified locally in PR feat(server,mac): no-auth mode for embedded server (LOBU_NO_AUTH=1) #779 follow-up testing).
  • If a row genuinely fails to persist, server logs show the connector/feed/origin context and the stream handler returns a clear error (not `Cannot read properties of undefined`).
  • Conflict-update race (delete existing row between find and reread) falls through to fresh insert instead of crashing.

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Improved event persistence validation to ensure data integrity and prevent silent failures when events are not properly saved.
    • Enhanced error diagnostics and logging to provide clearer messages when event insertion fails, making troubleshooting easier.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 55b9e8ca-7366-477c-aa01-6301531dbba5

📥 Commits

Reviewing files that changed from the base of the PR and between a32985c and c8715e1.

📒 Files selected for processing (1)
  • packages/server/src/utils/insert-event.ts

📝 Walkthrough

Walkthrough

insertEvent now re-queries events by ID in the conflict-update path before handling embeddings, with fallthrough logic for race conditions. It also validates INSERT RETURNING results and throws a descriptive error with full context when persistence fails.

Changes

Event Insertion Robustness

Layer / File(s) Summary
Conflict reread and embedding handling
packages/server/src/utils/insert-event.ts
In the onConflictUpdate semantically-equal branch, replaces existing-row handling with a reread of the event by ID; if the reread succeeds it upserts embeddings and returns the row; if the reread is empty it logs a warning and falls through to the INSERT path without setting supersedesEventId.
INSERT result validation and error handling
packages/server/src/utils/insert-event.ts
After the INSERT attempt (including retry on events_client_id_fkey violations), checks whether the INSERT RETURNING result contains a row; if result[0] is missing it logs diagnostic context (origin/connection/org/semantic/connector/feed identifiers) and throws a new error indicating the event was not persisted, instead of failing later on dereferencing inserted.id.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

A rabbit hops through SQL with care,
Re-reads the rows before they're there,
When conflicts bloom, we check once more—
Then validate what we've stored before! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change in the pull request—adding guards in insert-event.ts to handle empty INSERT RETURNING results.
Description check ✅ Passed The description covers all required template sections: Summary explains the problem and root cause, Test plan lists validation steps, and Notes are minimal but appropriate.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/insert-event-empty-returning

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/server/src/utils/insert-event.ts (1)

230-238: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move embedding upsert after confirming the existing row still exists.

upsertEmbedding(existing.id, ...) still runs before the reread. If the row is deleted in that window, this path can still fail with FK errors instead of falling through to fresh insert.

Proposed fix
       if (isSemanticallyEqual(existing, params)) {
-        await upsertEmbedding(existing.id, params.embedding, sql);
         const existingRows = await sql`
           SELECT id, entity_ids, origin_id, title, semantic_type, created_at
           FROM events
           WHERE id = ${existing.id}
           LIMIT 1
         `;
         const existingRow = existingRows[0] as InsertedEvent | undefined;
-        if (existingRow) return existingRow;
+        if (existingRow) {
+          await upsertEmbedding(existing.id, params.embedding, sql);
+          return existingRow;
+        }
         // Race: the existing row was deleted/tombstoned between the
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/utils/insert-event.ts` around lines 230 - 238, The
upsertEmbedding call runs before re-reading the event and can trigger FK errors
if the event was deleted; move the upsertEmbedding(existing.id, ...) call so it
only runs after the SELECT re-read confirms an existingRow is present (i.e.,
after const existingRow = existingRows[0] ... and after the if (existingRow)
return existingRow), and for the fresh-insert path call upsertEmbedding with the
newly inserted event id (use the id returned by the insert flow). Update calls
to use the confirmed existingRow.id or the inserted id to avoid orphaned
embedding FK failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/index.ts`:
- Around line 312-317: The no-auth Origin check currently treats any loopback
origin as same-origin; tighten it by comparing the incoming Origin exactly to
this server's actual origin instead of allowing any localhost/127/::1. Compute
the server origin (e.g., build serverOrigin from the server's hostname and port
or use the canonical origin value your server exposes), then change the
condition around sameOrigin to: sfs === 'same-origin' || sfs === 'none' ||
origin === serverOrigin; keep the trustedNative check (lobuClient) as-is and use
sameOrigin and trustedNative in the existing if (!sameOrigin && !trustedNative)
branch.

In `@packages/server/src/start-local.ts`:
- Around line 421-429: The current early return when otherUserCountRows > 0 can
hide a missing bootstrap user under LOBU_NO_AUTH, causing resolveAuth() to later
block; change the logic in start-local.ts around the BOOTSTRAP_USER_ID check to
explicitly verify the bootstrap user exists before returning, and if
LOBU_NO_AUTH (env var) is enabled and the bootstrap user is not present, fail
fast by throwing an error or exiting so the process won’t start in a broken
state; locate the checks using BOOTSTRAP_USER_ID and the surrounding bootstrap
PAT provisioning code and ensure the new branch triggers a hard failure when
no-auth is set and bootstrap-user is absent so resolveAuth() cannot deadlock.

In `@packages/server/src/workspace/multi-tenant.ts`:
- Around line 117-163: The module-level cache noAuthUserCache must be cleared by
the test teardown hook; update the existing test-clear routine
(clearMultiTenantCachesForTests) to reset noAuthUserCache = null (or add a call
in that function to clear this module's cache) so tests that swap bootstrap rows
or env won't reuse stale no-auth context—look for the
clearMultiTenantCachesForTests function and add logic to set noAuthUserCache to
null or expose a small clearNoAuthUserCache helper and call it from that clear
function.

---

Outside diff comments:
In `@packages/server/src/utils/insert-event.ts`:
- Around line 230-238: The upsertEmbedding call runs before re-reading the event
and can trigger FK errors if the event was deleted; move the
upsertEmbedding(existing.id, ...) call so it only runs after the SELECT re-read
confirms an existingRow is present (i.e., after const existingRow =
existingRows[0] ... and after the if (existingRow) return existingRow), and for
the fresh-insert path call upsertEmbedding with the newly inserted event id (use
the id returned by the insert flow). Update calls to use the confirmed
existingRow.id or the inserted id to avoid orphaned embedding FK failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: abc0c9ee-2de6-46bd-9a17-e34cdcf1ff6b

📥 Commits

Reviewing files that changed from the base of the PR and between a3e6f0a and 31c26f3.

📒 Files selected for processing (12)
  • apps/mac/Lobu/AppState.swift
  • apps/mac/Lobu/ChromeBridgeHost.swift
  • apps/mac/Lobu/LobuClient.swift
  • apps/mac/Lobu/LocalLobuRunner.swift
  • apps/mac/Lobu/MenuBarContent.swift
  • apps/mac/Lobu/OAuthClient.swift
  • packages/server/src/index.ts
  • packages/server/src/server.ts
  • packages/server/src/start-local.ts
  • packages/server/src/utils/insert-event.ts
  • packages/server/src/utils/loopback.ts
  • packages/server/src/workspace/multi-tenant.ts

Comment on lines +312 to +317
const sameOrigin =
sfs === 'same-origin' ||
sfs === 'none' ||
(origin !== undefined && /^https?:\/\/(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])(?::\d+)?$/.test(origin));
const trustedNative = lobuClient !== undefined && lobuClient.length > 0;
if (!sameOrigin && !trustedNative) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten the no-auth Origin check to the actual server origin.

This currently trusts any loopback Origin, so a page on http://localhost:3000 can mutate http://127.0.0.1:8787 in no-auth mode. That broadens the CSRF trust boundary from same-origin to any local web app.

🔧 Proposed fix
+  const requestOrigin = new URL(c.req.url).origin;
   const sameOrigin =
     sfs === 'same-origin' ||
     sfs === 'none' ||
-    (origin !== undefined && /^https?:\/\/(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])(?::\d+)?$/.test(origin));
+    origin === requestOrigin;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sameOrigin =
sfs === 'same-origin' ||
sfs === 'none' ||
(origin !== undefined && /^https?:\/\/(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\])(?::\d+)?$/.test(origin));
const trustedNative = lobuClient !== undefined && lobuClient.length > 0;
if (!sameOrigin && !trustedNative) {
const requestOrigin = new URL(c.req.url).origin;
const sameOrigin =
sfs === 'same-origin' ||
sfs === 'none' ||
origin === requestOrigin;
const trustedNative = lobuClient !== undefined && lobuClient.length > 0;
if (!sameOrigin && !trustedNative) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/index.ts` around lines 312 - 317, The no-auth Origin
check currently treats any loopback origin as same-origin; tighten it by
comparing the incoming Origin exactly to this server's actual origin instead of
allowing any localhost/127/::1. Compute the server origin (e.g., build
serverOrigin from the server's hostname and port or use the canonical origin
value your server exposes), then change the condition around sameOrigin to: sfs
=== 'same-origin' || sfs === 'none' || origin === serverOrigin; keep the
trustedNative check (lobuClient) as-is and use sameOrigin and trustedNative in
the existing if (!sameOrigin && !trustedNative) branch.

Comment on lines +421 to 429
const otherUserCountRows = await sql<[{ count: number }]>`
SELECT count(*)::int AS count FROM "user" WHERE id <> ${BOOTSTRAP_USER_ID}
`;
if ((userCountRows[0]?.count ?? 0) > 0) {
if ((otherUserCountRows[0]?.count ?? 0) > 0) {
logger.debug(
{ userCount: userCountRows[0]?.count },
'Skipping bootstrap PAT — deployment already has users'
{ userCount: otherUserCountRows[0]?.count },
'Skipping bootstrap PAT — deployment already has non-bootstrap users'
);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast here when no-auth cannot provision its bootstrap identity.

With LOBU_NO_AUTH=1, returning here on an existing local DB leaves bootstrap-user absent, but resolveAuth() only ever resolves that fixed user/org pair. The server still boots and every request gets stuck at 503 server_not_ready.

🔧 Proposed fix
     if ((otherUserCountRows[0]?.count ?? 0) > 0) {
+      if (process.env.LOBU_NO_AUTH === '1') {
+        throw new Error(
+          'LOBU_NO_AUTH=1 requires the bootstrap user/org, but non-bootstrap users already exist'
+        );
+      }
       logger.debug(
         { userCount: otherUserCountRows[0]?.count },
         'Skipping bootstrap PAT — deployment already has non-bootstrap users'
       );
       return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/start-local.ts` around lines 421 - 429, The current early
return when otherUserCountRows > 0 can hide a missing bootstrap user under
LOBU_NO_AUTH, causing resolveAuth() to later block; change the logic in
start-local.ts around the BOOTSTRAP_USER_ID check to explicitly verify the
bootstrap user exists before returning, and if LOBU_NO_AUTH (env var) is enabled
and the bootstrap user is not present, fail fast by throwing an error or exiting
so the process won’t start in a broken state; locate the checks using
BOOTSTRAP_USER_ID and the surrounding bootstrap PAT provisioning code and ensure
the new branch triggers a hard failure when no-auth is set and bootstrap-user is
absent so resolveAuth() cannot deadlock.

Comment on lines +117 to +163
let noAuthUserCache: NoAuthUser | null = null;

interface NoAuthUser {
userId: string;
organizationId: string;
user: { id: string; email: string; name: string; username: string };
}

/// Resolve the single local user attributed to every request in no-auth
/// mode. Pulls the bootstrap-user row + the bootstrap-org membership by
/// **id pair**, not "first admin LIMIT 1" — if the bootstrap user ever
/// has multiple memberships (e.g. someone manually added them to another
/// org for testing) we still want the personal org, deterministically.
/// Returns `null` only when the row truly doesn't exist (server boot race
/// between HTTP listen and ensureBootstrapPat's await — `start-local.ts`
/// now runs the bootstrap BEFORE listen() to close that race).
async function getNoAuthUser(
sql: ReturnType<typeof getDb>
): Promise<NoAuthUser | null> {
if (noAuthUserCache) return noAuthUserCache;
const rows = await simpleQuery(sql`
SELECT
u.id AS user_id,
u.email AS email,
u.name AS name,
u.username AS username
FROM "user" u
JOIN "member" m ON m."userId" = u.id
WHERE u.id = ${NO_AUTH_USER_ID}
AND m."organizationId" = ${NO_AUTH_ORG_ID}
AND m.role IN ('owner', 'admin')
LIMIT 1
`);
if (rows.length === 0) return null;
const row = rows[0];
noAuthUserCache = {
userId: row.user_id as string,
organizationId: NO_AUTH_ORG_ID,
user: {
id: row.user_id as string,
email: (row.email as string) ?? '',
name: (row.name as string) ?? '',
username: (row.username as string) ?? '',
},
};
return noAuthUserCache;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset noAuthUserCache from the existing test clear hook.

This cache is module-local and never cleared by clearMultiTenantCachesForTests, so once one test resolves the bootstrap user, later cases can reuse stale no-auth auth context after swapping rows or env.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/workspace/multi-tenant.ts` around lines 117 - 163, The
module-level cache noAuthUserCache must be cleared by the test teardown hook;
update the existing test-clear routine (clearMultiTenantCachesForTests) to reset
noAuthUserCache = null (or add a call in that function to clear this module's
cache) so tests that swap bootstrap rows or env won't reuse stale no-auth
context—look for the clearMultiTenantCachesForTests function and add logic to
set noAuthUserCache to null or expose a small clearNoAuthUserCache helper and
call it from that clear function.

buremba added 2 commits May 17, 2026 01:45
`insertEvent` assumed `INSERT ... RETURNING` always yields a row, then
dereferenced `result[0].id` to feed `upsertEmbedding`. When the result
came back empty — exposed by the menu bar's no-auth sync round-trip
completing for the first time — the call crashed with the cryptic
`TypeError: Cannot read properties of undefined (reading 'id')` and
the worker stream returned 500.

Two defensive guards:

1. After the main INSERT path, treat `result[0]` as possibly undefined.
   Log connector/feed/origin context and throw a real error explaining
   the row wasn't persisted. Callers (worker streamContent) catch the
   throw and surface a useful message instead of the stack-trace blob.

2. In the conflict-update early-return path, `existingRows[0]` could
   also be undefined if the found row was deleted/tombstoned between
   `findCurrentEventByOrigin` and the subsequent reread (race). Fall
   through to the fresh-insert path instead of crashing.

Root cause of the original empty-result is still under investigation —
likely a PGlite quirk with the new `search_tsv tsvector GENERATED ALWAYS
AS (...) STORED` column from PR #765, or a transient state during
bootstrap-org/connector wire-up. Either way, the right behavior is to
fail loud with context rather than crash on `undefined.id`.
The conflict-update early-return path called upsertEmbedding(existing.id, ...)
before the SELECT that confirms the row is still there. If the row got
deleted/tombstoned in that window, upsertEmbedding would FK-fail with a
confusing error instead of letting us fall through to a fresh insert.

Reorder so the embedding upsert only fires when the reread confirms the
event still exists. Fall-through behavior on the race is unchanged.

The 3 other CodeRabbit comments on this PR were against code from PR #779
(now merged) — those will land as separate follow-ups since they're out
of scope here.
@buremba buremba force-pushed the fix/insert-event-empty-returning branch from a32985c to c8715e1 Compare May 17, 2026 00:45
@buremba buremba merged commit 5226d99 into main May 17, 2026
14 of 18 checks passed
@buremba buremba deleted the fix/insert-event-empty-returning branch May 17, 2026 00:45
buremba added a commit that referenced this pull request May 17, 2026
Three deferred items from CodeRabbit's review of the no-auth work
that were outside that PR's diff but in scope for follow-up:

1. CSRF middleware (index.ts): Origin check was any-loopback-shape,
   which let `http://localhost:9999` (or any malicious tab loaded
   from another loopback port) pass as same-origin against our
   `:8787`. Tighten to an exact match against the canonical origin
   derived from the validated Host header.

2. Bootstrap (start-local.ts): if LOBU_NO_AUTH=1 is set but the
   bootstrap user doesn't exist because OTHER (non-bootstrap) users
   already populate the deployment, the previous code silently
   skipped bootstrap. resolveAuth() would then return 503 for every
   request — visible only at request time, with no clear recovery
   path. Fail loud at startup instead, with a message naming both
   the cause (env vs. existing users) and the fix (clear data dir
   or unset env).

3. multi-tenant.ts: `noAuthUserCache` is a module-level variable
   inside this file, not in `multi-tenant-caches.ts` where the
   shared test teardown lives. Wrap the shared cache-clearer with a
   local version that also resets noAuthUserCache so tests that
   swap bootstrap rows / env between cases don't see stale identity.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants