From 8b212d829825de2fc40402f16bd3368eb7d38580 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 5 May 2026 19:41:37 -0400 Subject: [PATCH] Move cross-instance NOTIFY behind DBAdapter.notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realm.#notifyFileChange was issuing raw `SELECT pg_notify(...)` SQL against this.#dbAdapter. In the host/browser context the adapter is SQLite, which has no pg_notify function — every write logged `SQLITE_ERROR: no such function: pg_notify`. The host runs a single in-process realm with no peers to notify, so issuing the SQL was also semantically a no-op even when it didn't error. Push the cross-instance broadcast capability down into the existing DBAdapter abstraction: - Add `notify(channel, payload): Promise` to the DBAdapter interface, documented as best-effort cache-coherency. - PgAdapter implements via `SELECT pg_notify($1, $2)`. - SQLiteAdapter implements as a no-op (no pub/sub primitive, no peers). - Realm.#notifyFileChange calls `this.#dbAdapter.notify(...)` and no longer embeds pg-specific SQL. - Update the realm-server listener test to drive notifications via the new method (also gives PgAdapter.notify direct CI coverage). - Add `notify` to the inline DBAdapter mocks in bot-runner / realm-server / runtime-common tests. Surfaced as flaky test-log noise after PR #4660 (CS-10892: realm_file_changes NOTIFY channel) widened the call site without gating SQLite. Original diagnosis and the kind === 'sqlite' guard fix from Hassan Abdel-Rahman on cs-11036-indexer-resume-progress. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/bot-runner/tests/bot-runner-test.ts | 1 + .../bot-runner/tests/command-runner-test.ts | 7 ++++++ packages/host/app/lib/sqlite-adapter.ts | 4 ++++ packages/postgres/pg-adapter.ts | 6 +++++ .../tests/prerender-proxy-test.ts | 1 + .../tests/realm-file-changes-listener-test.ts | 23 +++++++------------ .../tests/screenshot-card-test.ts | 1 + packages/runtime-common/db.ts | 5 ++++ packages/runtime-common/realm.ts | 16 ++++++------- .../tests/run-command-task-shared-tests.ts | 1 + 10 files changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 872b8bd400a..2560eda7815 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -112,6 +112,7 @@ module('timeline handler', () => { dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_registrations br')) { diff --git a/packages/bot-runner/tests/command-runner-test.ts b/packages/bot-runner/tests/command-runner-test.ts index 7e9d82ca861..ab8193f7afe 100644 --- a/packages/bot-runner/tests/command-runner-test.ts +++ b/packages/bot-runner/tests/command-runner-test.ts @@ -84,6 +84,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { @@ -228,6 +229,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { @@ -440,6 +442,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { @@ -534,6 +537,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { @@ -671,6 +675,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { @@ -883,6 +888,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { @@ -1002,6 +1008,7 @@ module('command runner', () => { ]); let dbAdapter = { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { if (sql.includes('FROM bot_commands WHERE bot_id =')) { diff --git a/packages/host/app/lib/sqlite-adapter.ts b/packages/host/app/lib/sqlite-adapter.ts index e611786a8ff..df8e599d1aa 100644 --- a/packages/host/app/lib/sqlite-adapter.ts +++ b/packages/host/app/lib/sqlite-adapter.ts @@ -53,6 +53,10 @@ export default class SQLiteAdapter implements DBAdapter { return await this.internalExecute(sql, opts); } + // SQLite has no pub/sub primitive and the host runs a single in-process + // realm with no peers to notify, so this is intentionally a no-op. + async notify(_channel: string, _payload: string): Promise {} + private async internalExecute(sql: string, opts?: ExecuteOptions) { sql = this.adjustSQL(sql); return await this.query(sql, opts); diff --git a/packages/postgres/pg-adapter.ts b/packages/postgres/pg-adapter.ts index 26246f10c6c..86e694aa2b1 100644 --- a/packages/postgres/pg-adapter.ts +++ b/packages/postgres/pg-adapter.ts @@ -112,6 +112,12 @@ export class PgAdapter implements DBAdapter { } } + async notify(channel: string, payload: string): Promise { + await this.execute('SELECT pg_notify($1, $2)', { + bind: [channel, payload], + }); + } + async listen( channel: string, handler: (notification: Notification) => void, diff --git a/packages/realm-server/tests/prerender-proxy-test.ts b/packages/realm-server/tests/prerender-proxy-test.ts index 2fe79955a57..a739a359b1e 100644 --- a/packages/realm-server/tests/prerender-proxy-test.ts +++ b/packages/realm-server/tests/prerender-proxy-test.ts @@ -20,6 +20,7 @@ module(basename(__filename), function () { function makeDbAdapter(rows: any[]): DBAdapter { return { kind: 'pg', + async notify() {}, isClosed: false, async execute() { return rows; diff --git a/packages/realm-server/tests/realm-file-changes-listener-test.ts b/packages/realm-server/tests/realm-file-changes-listener-test.ts index 7ce9c80c87f..42580e0c90f 100644 --- a/packages/realm-server/tests/realm-file-changes-listener-test.ts +++ b/packages/realm-server/tests/realm-file-changes-listener-test.ts @@ -2,7 +2,6 @@ import { module, test } from 'qunit'; import { basename } from 'path'; import type { PgAdapter } from '@cardstack/postgres'; import type { Realm } from '@cardstack/runtime-common'; -import { query, param } from '@cardstack/runtime-common'; import { setupDB } from './helpers'; import { RealmFileChangesListener, @@ -167,13 +166,10 @@ module(basename(__filename), function () { // Give the LISTEN connection a moment to subscribe. await new Promise((r) => setTimeout(r, 100)); - await query(dbAdapter, [ - `SELECT pg_notify(`, - param('realm_file_changes'), - `,`, - param(`${realmUrl}:src/greeting.gts`), - `)`, - ]); + await dbAdapter.notify( + 'realm_file_changes', + `${realmUrl}:src/greeting.gts`, + ); const received = await waitFor(() => invalidations.length > 0 ? invalidations : undefined, @@ -199,13 +195,10 @@ module(basename(__filename), function () { try { await new Promise((r) => setTimeout(r, 100)); - await query(dbAdapter, [ - `SELECT pg_notify(`, - param('realm_file_changes'), - `,`, - param(`http://x.test/not-mounted/:file.gts`), - `)`, - ]); + await dbAdapter.notify( + 'realm_file_changes', + `http://x.test/not-mounted/:file.gts`, + ); // Wait for the lookup to be recorded (proves the NOTIFY was received // and dispatched; the lookup miss then silently drops). diff --git a/packages/realm-server/tests/screenshot-card-test.ts b/packages/realm-server/tests/screenshot-card-test.ts index 7e8958a73fc..fd29f312f1f 100644 --- a/packages/realm-server/tests/screenshot-card-test.ts +++ b/packages/realm-server/tests/screenshot-card-test.ts @@ -24,6 +24,7 @@ module(basename(__filename), function () { function makeDbAdapter(): DBAdapter { return { kind: 'pg', + async notify() {}, isClosed: false, async execute() { return []; diff --git a/packages/runtime-common/db.ts b/packages/runtime-common/db.ts index 7e92381cff7..9f327683a72 100644 --- a/packages/runtime-common/db.ts +++ b/packages/runtime-common/db.ts @@ -20,4 +20,9 @@ export interface DBAdapter { ) => Promise[]>; close: () => Promise; getColumnNames: (tableName: string) => Promise; + // Best-effort cross-instance broadcast on a named channel. Backends that + // don't support pub/sub (e.g. in-process SQLite) implement this as a no-op: + // the caller must treat it as fire-and-forget cache-coherency, never as + // delivery-guaranteed messaging. + notify: (channel: string, payload: string) => Promise; } diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index ec3c5aea1ab..ffa8d83c5b1 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1125,19 +1125,17 @@ export class Realm { // same path. Best-effort — failures are logged and swallowed because the // local write already succeeded and a missed NOTIFY is a bounded cache- // staleness window (see docs §9 "Cache-invalidation NOTIFY missed"), not - // a correctness failure. + // a correctness failure. Adapters without pub/sub (e.g. SQLite in the + // host/browser context) implement notify as a no-op. async #notifyFileChange(path: LocalPath): Promise { try { - await query(this.#dbAdapter, [ - `SELECT pg_notify(`, - param(REALM_FILE_CHANGES_CHANNEL), - `,`, - param(`${this.url}:${path}`), - `)`, - ]); + await this.#dbAdapter.notify( + REALM_FILE_CHANGES_CHANNEL, + `${this.url}:${path}`, + ); } catch (err: unknown) { this.#log.warn( - `pg_notify ${REALM_FILE_CHANGES_CHANNEL} failed for ${this.url}:${path}: ${String(err)}`, + `notify ${REALM_FILE_CHANGES_CHANNEL} failed for ${this.url}:${path}: ${String(err)}`, ); } } diff --git a/packages/runtime-common/tests/run-command-task-shared-tests.ts b/packages/runtime-common/tests/run-command-task-shared-tests.ts index 824eaaaab8e..3784a2f5f5b 100644 --- a/packages/runtime-common/tests/run-command-task-shared-tests.ts +++ b/packages/runtime-common/tests/run-command-task-shared-tests.ts @@ -15,6 +15,7 @@ function makeDBAdapter( ): DBAdapter { return { kind: 'pg', + notify: async () => {}, isClosed: false, execute: async (sql: string, opts?: ExecuteOptions) => { assertion?.(sql, opts);