@happyvertical/smrt-core 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +8 -8
  2. package/agents/change-feed.md +3 -2
  3. package/agents/generators.md +34 -0
  4. package/agents/revision-guard.md +66 -0
  5. package/dist/browser.js +2 -1
  6. package/dist/cascade.d.ts +5 -9
  7. package/dist/cascade.d.ts.map +1 -1
  8. package/dist/cascade.js +65 -30
  9. package/dist/cascade.js.map +1 -1
  10. package/dist/change-feed.d.ts +60 -1
  11. package/dist/change-feed.d.ts.map +1 -1
  12. package/dist/change-feed.js +464 -27
  13. package/dist/change-feed.js.map +1 -1
  14. package/dist/change-signals.d.ts.map +1 -1
  15. package/dist/change-signals.js +13 -11
  16. package/dist/change-signals.js.map +1 -1
  17. package/dist/embedded-write-queue.d.ts +8 -0
  18. package/dist/embedded-write-queue.d.ts.map +1 -1
  19. package/dist/embedded-write-queue.js +12 -2
  20. package/dist/embedded-write-queue.js.map +1 -1
  21. package/dist/generators/cli.d.ts.map +1 -1
  22. package/dist/generators/cli.js +10 -18
  23. package/dist/generators/cli.js.map +1 -1
  24. package/dist/generators/custom-action.d.ts +216 -0
  25. package/dist/generators/custom-action.d.ts.map +1 -1
  26. package/dist/generators/custom-action.js +256 -1
  27. package/dist/generators/custom-action.js.map +1 -1
  28. package/dist/generators/index.d.ts +2 -1
  29. package/dist/generators/index.d.ts.map +1 -1
  30. package/dist/generators/index.js +3 -2
  31. package/dist/generators/mcp.d.ts.map +1 -1
  32. package/dist/generators/mcp.js +14 -39
  33. package/dist/generators/mcp.js.map +1 -1
  34. package/dist/generators/preflight-route.d.ts +151 -0
  35. package/dist/generators/preflight-route.d.ts.map +1 -0
  36. package/dist/generators/preflight-route.js +194 -0
  37. package/dist/generators/preflight-route.js.map +1 -0
  38. package/dist/generators/rest.d.ts +12 -0
  39. package/dist/generators/rest.d.ts.map +1 -1
  40. package/dist/generators/rest.js +14 -15
  41. package/dist/generators/rest.js.map +1 -1
  42. package/dist/generators/tool-schema.d.ts.map +1 -1
  43. package/dist/generators/tool-schema.js +2 -8
  44. package/dist/generators/tool-schema.js.map +1 -1
  45. package/dist/generators.js +3 -2
  46. package/dist/index.d.ts +3 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +7 -4
  49. package/dist/knowledge.d.ts.map +1 -1
  50. package/dist/knowledge.js +283 -35
  51. package/dist/knowledge.js.map +1 -1
  52. package/dist/manifest/static-manifest.d.ts.map +1 -1
  53. package/dist/manifest/static-manifest.js +6 -2
  54. package/dist/manifest/static-manifest.js.map +1 -1
  55. package/dist/manifest/store.js +1 -1
  56. package/dist/manifest/store.js.map +1 -1
  57. package/dist/manifest.json +8 -2
  58. package/dist/object.d.ts +31 -1
  59. package/dist/object.d.ts.map +1 -1
  60. package/dist/object.js +57 -9
  61. package/dist/object.js.map +1 -1
  62. package/dist/registry/framework-base-classes.d.ts +10 -0
  63. package/dist/registry/framework-base-classes.d.ts.map +1 -0
  64. package/dist/registry/framework-base-classes.js +92 -0
  65. package/dist/registry/framework-base-classes.js.map +1 -0
  66. package/dist/registry/schema-builder.d.ts.map +1 -1
  67. package/dist/registry/schema-builder.js +2 -0
  68. package/dist/registry/schema-builder.js.map +1 -1
  69. package/dist/registry/types.d.ts +27 -6
  70. package/dist/registry/types.d.ts.map +1 -1
  71. package/dist/registry.d.ts +1 -0
  72. package/dist/registry.d.ts.map +1 -1
  73. package/dist/registry.js +2 -1
  74. package/dist/registry.js.map +1 -1
  75. package/dist/revision-guard.d.ts +84 -0
  76. package/dist/revision-guard.d.ts.map +1 -0
  77. package/dist/revision-guard.js +120 -0
  78. package/dist/revision-guard.js.map +1 -0
  79. package/dist/scanner/manifest-generator.d.ts +46 -16
  80. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  81. package/dist/scanner/manifest-generator.js +198 -45
  82. package/dist/scanner/manifest-generator.js.map +1 -1
  83. package/dist/smrt-knowledge.json +18 -9
  84. package/dist/system/bootstrap.d.ts.map +1 -1
  85. package/dist/system/bootstrap.js +2 -1
  86. package/dist/system/bootstrap.js.map +1 -1
  87. package/dist/system/schema.d.ts +75 -2
  88. package/dist/system/schema.d.ts.map +1 -1
  89. package/dist/system/schema.js +326 -4
  90. package/dist/system/schema.js.map +1 -1
  91. package/dist/vite-plugin/index.d.ts.map +1 -1
  92. package/dist/vite-plugin/index.js +48 -23
  93. package/dist/vite-plugin/index.js.map +1 -1
  94. package/dist/vite-plugin/sveltekit-generator.d.ts +33 -7
  95. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  96. package/dist/vite-plugin/sveltekit-generator.js +88 -17
  97. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  98. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  99. package/dist/vite-plugin/sync-apply-route.js +2 -0
  100. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  101. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  102. package/dist/vite-plugin/web-collections.js +28 -5
  103. package/dist/vite-plugin/web-collections.js.map +1 -1
  104. package/package.json +4 -4
@@ -5,7 +5,7 @@ import { resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
5
5
  import { isEmbeddedDatabase, withEmbeddedWriteQueue } from "./embedded-write-queue.js";
6
6
  import { GlobalInterceptors } from "./interceptors.js";
7
7
  import { detectEngine } from "./schema/ddl/index.js";
8
- import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES } from "./system/schema.js";
8
+ import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME, POSTGRES_CHANGE_FEED_HELPER_MARKER, POSTGRES_CHANGE_FEED_PENDING_TABLE, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES } from "./system/schema.js";
9
9
  import { SYSTEM_TABLE_NAMES } from "./schema/system-table-shapes.js";
10
10
  import { createLogger } from "@happyvertical/logger";
11
11
  //#region src/change-feed.ts
@@ -49,6 +49,46 @@ import { createLogger } from "@happyvertical/logger";
49
49
  * auto-increment mechanism exists in the system-table schema path either;
50
50
  * see `system/schema.ts`.)
51
51
  *
52
+ * ## Staged appends inside caller transactions (PostgreSQL, #2649)
53
+ *
54
+ * `MAX+1` allocation costs a *wait*: two appends that pick the same value
55
+ * conflict on the primary key, and the loser waits for the winner's
56
+ * transaction to end. Under autocommit that is one statement. Inside a
57
+ * caller-managed transaction it is the whole transaction — and a long write
58
+ * transaction that appends and then keeps taking row locks forms a genuine
59
+ * lock cycle with any writer that took those row locks first and then
60
+ * appended. PostgreSQL detects it (`40P01`) and aborts one side, so an
61
+ * ordinary concurrent request could abort a legitimate long write.
62
+ *
63
+ * PostgreSQL appends issued inside a caller transaction are therefore
64
+ * **staged**: `_smrt_append_change` inserts into `_smrt_changes_pending`,
65
+ * whose identity key conflicts with nothing, so the append never waits on
66
+ * another transaction and the cycle cannot form. The staged row is still
67
+ * fate-shared with the caller (a rollback removes it). {@link drainChangeFeed}
68
+ * — run by {@link getChangesSince} before it reads, and (throttled) by the
69
+ * append path itself — moves *committed* staged rows into `_smrt_changes`
70
+ * under a try-only advisory lock, numbering them `MAX(seq) + row_number()` in
71
+ * staged order. Draining is driven from JavaScript rather than inside the
72
+ * append helper so that every sequenced entry also gets its live `_events`
73
+ * signal: an entry sequenced invisibly server-side would be skipped for good
74
+ * by a subscriber whose `Last-Event-ID` came from a later, higher sequence.
75
+ * Signals for drained entries are published only once the drain is proven
76
+ * committed, so a rollback can never advertise a sequence another appender
77
+ * then reuses.
78
+ *
79
+ * The cursor guarantee is unchanged, because sequences are still allocated by
80
+ * exactly one `MAX+1` writer at a time and only ever for already-committed
81
+ * work: committed sequences stay contiguous, and no entry can appear at or
82
+ * below a horizon a reader already observed. What a staged append gives up is
83
+ * *promptness*, not durability or order: its entry becomes visible one drain
84
+ * after its transaction commits, and its position in the log is its drain
85
+ * order rather than its statement order. An autocommit append drains before
86
+ * allocating whenever its throttle allows, so staged work usually keeps its
87
+ * place ahead of later writes.
88
+ *
89
+ * SQLite and DuckDB keep the direct `MAX+1` insert unchanged — SQLite
90
+ * serializes writers outright, so the defect is unreachable there.
91
+ *
52
92
  * Contention note: appends serialize on the head of the log. Each append is
53
93
  * one small INSERT (issued from the write path *after* the user's row was
54
94
  * written), so the serialization window is one statement; conflicts resolve
@@ -68,7 +108,7 @@ import { createLogger } from "@happyvertical/logger";
68
108
  * JavaScript only throws/logs after PostgreSQL has restored the caller's
69
109
  * transaction, so a swallowed append failure cannot surface later as 25P02.
70
110
  * The append still joins a caller-managed transaction on the same handle and
71
- * shares its fate (a rollback removes the change row with the data row).
111
+ * shares its fate (a rollback removes the staged row with the data row).
72
112
  *
73
113
  * ## Known gaps (documented in the PRD)
74
114
  *
@@ -120,6 +160,7 @@ var CHANGE_FEED_TABLE = "_smrt_changes";
120
160
  */
121
161
  var CHANGE_FEED_EXCLUDED_TABLES = /* @__PURE__ */ new Set([
122
162
  ...SYSTEM_TABLE_NAMES,
163
+ POSTGRES_CHANGE_FEED_PENDING_TABLE,
123
164
  ...FRAMEWORK_OPERATIONAL_TABLES,
124
165
  ...RETIRED_SYSTEM_TABLES
125
166
  ]);
@@ -144,6 +185,83 @@ var MAX_CHANGES_LIMIT = 5e3;
144
185
  * blocking transaction commits, so a small bound is ample.
145
186
  */
146
187
  var MAX_APPEND_ATTEMPTS = 20;
188
+ /**
189
+ * Maximum bounded drain batches one {@link drainChangeFeed} call sequences.
190
+ * A cap rather than "until empty" so a pathological writer cannot make one
191
+ * reader drain forever; the remainder is picked up by the next drain.
192
+ */
193
+ var MAX_DRAIN_PASSES = 50;
194
+ /** Cap on signals held for a handle that keeps draining without committing. */
195
+ var MAX_DEFERRED_SIGNALS = 5e3;
196
+ /**
197
+ * Per-handle low-water mark of sequences a drain allocated but has not proven
198
+ * committed (#2649).
199
+ *
200
+ * The hold-back has to outlive the drain call that created it. A caller
201
+ * transaction can drain in one statement and read in the next — the second
202
+ * read's own drain allocates nothing (the server-side helper refuses once the
203
+ * transaction has an id), yet the transaction still sees the first drain's
204
+ * uncommitted rows and would serve them. Keyed on the handle object, which is
205
+ * the transaction's identity for its whole life, so every later read on the
206
+ * same transaction keeps holding back from the same mark.
207
+ *
208
+ * The mark is recorded unconditionally and *used* only while a transaction id
209
+ * is still assigned at read time. Under autocommit the drain committed as its
210
+ * own transaction, so the next read clears the mark and serves the rows.
211
+ */
212
+ var uncommittedDrainMarks = /* @__PURE__ */ new WeakMap();
213
+ /**
214
+ * How often the append path drains on its own behalf, per database.
215
+ *
216
+ * The append helper deliberately does not drain server-side: sequences it
217
+ * assigned there would never reach JavaScript, so no `_events` signal would be
218
+ * published for them while this append's own signal carries a higher sequence
219
+ * — and an EventSource resuming from that `Last-Event-ID` would skip them for
220
+ * good. Draining from here instead keeps every sequenced entry signalled, in
221
+ * ascending order, and keeps a deployment whose writers never read the feed
222
+ * from accumulating staged entries indefinitely. Throttled because it is a
223
+ * liveness convenience, not a correctness requirement: cursor readers drain
224
+ * for themselves, and `getTableVersion()` already counts staged entries.
225
+ */
226
+ var APPEND_DRAIN_INTERVAL_MS = 250;
227
+ /** dbKey → last time the append path drained for it. */
228
+ var lastAppendDrain = /* @__PURE__ */ new Map();
229
+ /** dbKeys that staged an append since this process last drained them. */
230
+ var stagedSinceLastDrain = /* @__PURE__ */ new Set();
231
+ function noteStagedAppend(db) {
232
+ try {
233
+ stagedSinceLastDrain.add(resolveDbCacheKey(db));
234
+ } catch {}
235
+ }
236
+ async function drainBeforeAppend(db) {
237
+ if (getEngine(db) !== "postgres") return [];
238
+ let dbKey;
239
+ try {
240
+ dbKey = resolveDbCacheKey(db);
241
+ } catch {
242
+ return [];
243
+ }
244
+ const now = Date.now();
245
+ const last = lastAppendDrain.get(dbKey);
246
+ if (!stagedSinceLastDrain.has(dbKey) && last !== void 0 && now - last < APPEND_DRAIN_INTERVAL_MS) return [];
247
+ stagedSinceLastDrain.delete(dbKey);
248
+ lastAppendDrain.set(dbKey, now);
249
+ try {
250
+ return (await drainChangeFeedDetailed(db, {
251
+ settle: false,
252
+ maxPasses: 1
253
+ })).unsettledSignals;
254
+ } catch (error) {
255
+ warnDrainFailureOnce(db, error);
256
+ return [];
257
+ }
258
+ }
259
+ function recordUncommittedDrainMark(db, firstAllocatedSeq) {
260
+ if (firstAllocatedSeq === null) return;
261
+ const key = db;
262
+ const existing = uncommittedDrainMarks.get(key);
263
+ if (existing === void 0 || firstAllocatedSeq < existing) uncommittedDrainMarks.set(key, firstAllocatedSeq);
264
+ }
147
265
  var VALID_OPERATIONS = /* @__PURE__ */ new Set([
148
266
  "create",
149
267
  "update",
@@ -199,14 +317,37 @@ function isUniqueViolation(error) {
199
317
  * framework already have the table via the system-table bootstrap.
200
318
  */
201
319
  var ensuredHandles = /* @__PURE__ */ new WeakSet();
202
- async function postgresChangeFeedAppendFunctionExists(db) {
203
- const rows = getQueryRows(await db.query(`SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`));
204
- return Boolean(rows[0]?.function_name);
320
+ /**
321
+ * SQL fragment that reports a helper as present ONLY when its body is current.
322
+ *
323
+ * `to_regprocedure()` alone answers "a function of this name and signature
324
+ * exists", which is not the same question: an install can hold a helper whose
325
+ * body predates a fix, and an existence probe would call it current and never
326
+ * replace it. Matching {@link POSTGRES_CHANGE_FEED_HELPER_MARKER} against
327
+ * `pg_proc.prosrc` makes the probe version-aware, so bumping the marker is all
328
+ * a future helper change needs to reach existing databases.
329
+ */
330
+ function currentHelperProbe(identity) {
331
+ return `(
332
+ SELECT p.oid
333
+ FROM pg_proc AS p
334
+ WHERE p.oid = to_regprocedure('${identity}')
335
+ AND p.prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'
336
+ )`;
337
+ }
338
+ async function postgresChangeFeedHelpersCurrent(db) {
339
+ const rows = getQueryRows(await db.query(`SELECT
340
+ ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,
341
+ ${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_function_name,
342
+ to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name`));
343
+ return Boolean(rows[0]?.function_name && rows[0]?.drain_function_name && rows[0]?.pending_table_name);
205
344
  }
206
345
  async function getPostgresChangeFeedSchemaState(db) {
207
346
  const rows = getQueryRows(await db.query(`SELECT
208
347
  to_regclass('${CHANGE_FEED_TABLE}') AS table_name,
209
- to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,
348
+ ${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,
349
+ to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name,
350
+ ${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_function_name,
210
351
  (
211
352
  SELECT data_type
212
353
  FROM information_schema.columns
@@ -217,6 +358,8 @@ async function getPostgresChangeFeedSchemaState(db) {
217
358
  return {
218
359
  tableExists: Boolean(rows[0]?.table_name),
219
360
  functionExists: Boolean(rows[0]?.function_name),
361
+ pendingTableExists: Boolean(rows[0]?.pending_table_name),
362
+ drainFunctionExists: Boolean(rows[0]?.drain_function_name),
220
363
  createdAtType: rows[0]?.created_at_type ? String(rows[0].created_at_type) : null
221
364
  };
222
365
  }
@@ -239,18 +382,45 @@ async function ensurePostgresChangeFeedAppendFunction(db, options = {}) {
239
382
  if (getEngine(db, options.typeHint) !== "postgres") return;
240
383
  assertPostgresChangeFeedTimestampCurrent(await getPostgresChangeFeedSchemaState(db));
241
384
  if (options.replaceExisting === false) {
242
- if (await postgresChangeFeedAppendFunctionExists(db)) return;
385
+ if (await postgresChangeFeedHelpersCurrent(db)) return;
243
386
  await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);
244
387
  return;
245
388
  }
246
389
  await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);
247
390
  }
391
+ /**
392
+ * Refresh the PostgreSQL change-feed helpers on an already-initialized
393
+ * database (issue #2649).
394
+ *
395
+ * `bootstrapSystemTables()` installs the helpers only while applying a system
396
+ * schema version, and #2649 changes the helper body without changing the
397
+ * portable system DDL — so a database already stamped with the current version
398
+ * would keep the pre-#2649 append function, and its `40P01` deadlock, until
399
+ * some feed route happened to call {@link ensureChangeFeedTable}. Ordinary
400
+ * model writes never do. Bootstrap therefore calls this before its
401
+ * version fast-return; it is one catalog probe when the helpers are current.
402
+ *
403
+ * Databases whose `_smrt_changes.created_at` is still the legacy
404
+ * timezone-naive column are left alone: they need their audited
405
+ * `migratePostgresSystemTimestamps()` pass first, and failing bootstrap on
406
+ * them here would be a new, unrelated break.
407
+ *
408
+ * @internal
409
+ */
410
+ async function ensurePostgresChangeFeedHelpers(db, typeHint) {
411
+ if (getEngine(db, typeHint) !== "postgres") return;
412
+ const state = await getPostgresChangeFeedSchemaState(db);
413
+ if (!state.tableExists) return;
414
+ if (state.createdAtType !== "timestamp with time zone") return;
415
+ if (state.functionExists && state.pendingTableExists && state.drainFunctionExists) return;
416
+ await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);
417
+ }
248
418
  async function ensureChangeFeedTable(db) {
249
419
  if (ensuredHandles.has(db)) return;
250
420
  if (getEngine(db) === "postgres") {
251
421
  const state = await getPostgresChangeFeedSchemaState(db);
252
422
  assertPostgresChangeFeedTimestampCurrent(state);
253
- if (state.tableExists && state.functionExists && state.createdAtType === "timestamp with time zone") {
423
+ if (state.tableExists && state.functionExists && state.pendingTableExists && state.drainFunctionExists && state.createdAtType === "timestamp with time zone") {
254
424
  ensuredHandles.add(db);
255
425
  return;
256
426
  }
@@ -273,6 +443,14 @@ async function ensureChangeFeedTable(db) {
273
443
  * conflicts or on any non-conflict database error; the framework's
274
444
  * interceptor catches and logs instead of failing the user's write.
275
445
  *
446
+ * **Returns `null` for a staged append** (PostgreSQL, #2649): an append issued
447
+ * inside a caller-managed transaction is written to `_smrt_changes_pending`
448
+ * and receives its sequence from the next {@link drainChangeFeed} after that
449
+ * transaction commits, so no sequence exists to return yet. The entry is
450
+ * durable and ordered — it simply is not numbered at this instant. Callers
451
+ * that need the sequence (an SSE event id, say) must treat `null` as "not
452
+ * available yet", never as a failure: a failure still throws.
453
+ *
276
454
  * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the
277
455
  * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception
278
456
  * handler is a PostgreSQL subtransaction: a failed attempt is rolled back
@@ -297,6 +475,27 @@ async function appendChange(db, input) {
297
475
  input.tenantId ?? null,
298
476
  (/* @__PURE__ */ new Date()).toISOString()
299
477
  ];
478
+ const drainedSignals = await drainBeforeAppend(db);
479
+ /**
480
+ * Settle the pre-append drain's signals from what this append revealed.
481
+ *
482
+ * A drain that allocated anything assigned a transaction id, so if this
483
+ * append then took the DIRECT path — `pg_current_xact_id_if_assigned()` was
484
+ * still null at its entry — that id is already gone, which can only mean the
485
+ * drain committed as its own autocommit transaction. Publishing here is
486
+ * therefore proven safe AND correctly ordered: these sequences are below the
487
+ * one this append is about to take, and the interceptor publishes that one
488
+ * only after this call returns. A deferred append proves nothing, so its
489
+ * signals go to the queue.
490
+ */
491
+ const settleDrainedSignals = (appended) => {
492
+ if (drainedSignals.length === 0) return;
493
+ if (appended === null) {
494
+ queueDeferredSignals(db, drainedSignals);
495
+ return;
496
+ }
497
+ publishSignals(db, drainedSignals);
498
+ };
300
499
  for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) try {
301
500
  const row = getQueryRows(await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () => db.query(sql, ...params)))[0];
302
501
  if (!row) throw new Error("Change feed append returned no result row");
@@ -305,13 +504,223 @@ async function appendChange(db, input) {
305
504
  error.code = String(row.error_code);
306
505
  throw error;
307
506
  }
308
- return toSeqNumber(engine === "postgres" ? row.allocated_seq : row.seq);
507
+ if (engine === "postgres" && row.allocated_seq == null) {
508
+ noteStagedAppend(db);
509
+ settleDrainedSignals(null);
510
+ return null;
511
+ }
512
+ const seq = toSeqNumber(engine === "postgres" ? row.allocated_seq : row.seq);
513
+ settleDrainedSignals(seq);
514
+ if (engine === "postgres") recordUncommittedDrainMark(db, seq);
515
+ return seq;
309
516
  } catch (error) {
310
- if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) throw error;
517
+ if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {
518
+ queueDeferredSignals(db, drainedSignals);
519
+ throw error;
520
+ }
311
521
  }
312
522
  throw new Error("appendChange exhausted retries without allocating a seq");
313
523
  }
314
524
  /**
525
+ * Sequence change-feed entries staged inside caller-managed transactions
526
+ * (issue #2649). PostgreSQL only; a no-op on every other engine.
527
+ *
528
+ * A `save()`/`delete()`/{@link appendChange} issued inside a caller
529
+ * transaction cannot allocate `MAX(seq) + 1` there: the allocation waits for
530
+ * any competing appender's transaction to end, and a long write transaction
531
+ * that goes on to take row locks the waiter holds closes a real lock cycle
532
+ * (`40P01`). Such appends are staged instead, and this call moves every
533
+ * **committed** staged entry into `_smrt_changes` with contiguous sequences,
534
+ * in staged order, under a try-only advisory lock.
535
+ *
536
+ * It runs by itself on the paths that matter — every autocommit append drains
537
+ * server-side before allocating its own sequence, and {@link getChangesSince}
538
+ * drains before it reads — so applications do not normally need to call it. A
539
+ * drain issued from inside a transaction that has already written is skipped
540
+ * server-side: allocating there would hold sequences uncommitted for the rest
541
+ * of that transaction, which is the wait this fix removes.
542
+ * Call it explicitly from a scheduled job when a deployment writes *only*
543
+ * through transactions and reads the feed from a connection that cannot write
544
+ * (a read replica or a read-only role), because neither self-draining path is
545
+ * then available.
546
+ *
547
+ * Best-effort by the feed's failure policy: a drain that cannot run leaves the
548
+ * staged entries in place for the next attempt and never throws into the
549
+ * caller's write path. Nothing is lost — staged entries are durable — they are
550
+ * simply not yet visible to cursor readers.
551
+ *
552
+ * @returns How many staged entries were sequenced.
553
+ */
554
+ async function drainChangeFeed(db) {
555
+ return (await drainChangeFeedDetailed(db)).drained;
556
+ }
557
+ /**
558
+ * {@link drainChangeFeed} plus the lowest sequence this call allocated.
559
+ *
560
+ * A drain runs on the caller's handle, so when that handle is inside a
561
+ * caller-managed transaction the rows it just wrote are visible **to this
562
+ * transaction only** and disappear if it rolls back. A reader that served them
563
+ * would report entries that never committed and advance its cursor past
564
+ * sequences a concurrent autocommit appender goes on to claim — a permanently
565
+ * skipped change. {@link getChangesSince} therefore refuses to serve at or
566
+ * above `firstAllocatedSeq`: everything below it was committed by somebody
567
+ * else before this drain started, and the drain's own rows are served by the
568
+ * next poll, once they are committed for everyone.
569
+ *
570
+ * @internal
571
+ */
572
+ async function drainChangeFeedDetailed(db, options = {}) {
573
+ if (getEngine(db) !== "postgres") return {
574
+ drained: 0,
575
+ firstAllocatedSeq: null,
576
+ unsettledSignals: []
577
+ };
578
+ const settle = options.settle !== false;
579
+ if (settle) await flushDeferredSignals(db);
580
+ let firstAllocatedSeq = null;
581
+ let drained = 0;
582
+ const unsettledSignals = [];
583
+ const maxPasses = Math.max(1, options.maxPasses ?? MAX_DRAIN_PASSES);
584
+ for (let pass = 0; pass < maxPasses; pass++) {
585
+ const rows = getQueryRows(await db.query(`SELECT * FROM ${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}()`));
586
+ const failure = rows.find((row) => row.error_code != null);
587
+ if (failure) {
588
+ const error = new Error(String(failure.error_message || "PostgreSQL change-feed drain failed"));
589
+ error.code = String(failure.error_code);
590
+ if (isUniqueViolation(error)) continue;
591
+ throw error;
592
+ }
593
+ const sequenced = rows.filter((row) => row.drained_seq != null);
594
+ drained += sequenced.length;
595
+ for (const row of sequenced) {
596
+ const seq = toSeqNumber(row.drained_seq);
597
+ if (firstAllocatedSeq === null || seq < firstAllocatedSeq) firstAllocatedSeq = seq;
598
+ }
599
+ const signals = sequenced.map((row) => ({
600
+ table: String(row.drained_table ?? ""),
601
+ operation: String(row.drained_operation ?? "update"),
602
+ rowId: row.drained_row_id == null ? null : String(row.drained_row_id),
603
+ tenantId: row.drained_tenant_id == null ? null : String(row.drained_tenant_id),
604
+ seq: toSeqNumber(row.drained_seq)
605
+ }));
606
+ if (signals.length > 0) if (!settle) unsettledSignals.push(...signals);
607
+ else if (await drainCommitted(db)) publishSignals(db, signals);
608
+ else queueDeferredSignals(db, signals);
609
+ if (sequenced.length === 0) break;
610
+ }
611
+ recordUncommittedDrainMark(db, firstAllocatedSeq);
612
+ return {
613
+ drained,
614
+ firstAllocatedSeq,
615
+ unsettledSignals
616
+ };
617
+ }
618
+ /** Signals a drain produced but could not yet prove committed. */
619
+ var deferredSignals = /* @__PURE__ */ new Map();
620
+ function publishSignals(db, signals) {
621
+ for (const signal of signals) try {
622
+ publishChangeSignal(db, signal);
623
+ } catch (error) {
624
+ warnSignalPublishFailureOnce(db, signal.table, error);
625
+ }
626
+ }
627
+ function queueDeferredSignals(db, signals) {
628
+ try {
629
+ const dbKey = resolveDbCacheKey(db);
630
+ const queued = deferredSignals.get(dbKey);
631
+ if (queued) {
632
+ queued.push(...signals);
633
+ if (queued.length > MAX_DEFERRED_SIGNALS) queued.splice(0, queued.length - MAX_DEFERRED_SIGNALS);
634
+ } else deferredSignals.set(dbKey, [...signals]);
635
+ } catch {}
636
+ }
637
+ /**
638
+ * Publish queued signals whose sequences are now committed.
639
+ *
640
+ * A queued entry's transaction may have rolled back, which returns its row to
641
+ * the staging table and frees its sequence for somebody else. Each queued
642
+ * signal is therefore verified against the committed log before it is
643
+ * published: the sequence must still carry the same table and row.
644
+ *
645
+ * An entry that does not verify is **kept, not dropped**. The queue is keyed
646
+ * per database, and every connection to that database shares it — so a second
647
+ * connection can reach this while the transaction that queued the entry is
648
+ * still open. Its own commit probe says "not in a transaction", but it cannot
649
+ * see the other connection's uncommitted rows, and discarding on that basis
650
+ * would strand a signal nobody can ever republish once that transaction
651
+ * commits. Unverified entries therefore wait for a later flush, bounded only
652
+ * by {@link MAX_DEFERRED_SIGNALS}, which evicts oldest-first — an entry whose
653
+ * transaction really did roll back is re-sequenced and re-signalled by a later
654
+ * drain regardless.
655
+ */
656
+ async function flushDeferredSignals(db) {
657
+ let dbKey;
658
+ try {
659
+ dbKey = resolveDbCacheKey(db);
660
+ } catch {
661
+ return;
662
+ }
663
+ const queued = deferredSignals.get(dbKey);
664
+ if (!queued || queued.length === 0) return;
665
+ deferredSignals.delete(dbKey);
666
+ const candidates = queued;
667
+ let unpublished = candidates;
668
+ try {
669
+ if (!await drainCommitted(db)) return;
670
+ unpublished = await publishVerifiedSignals(db, candidates);
671
+ } finally {
672
+ requeueDeferredSignals(dbKey, unpublished);
673
+ }
674
+ }
675
+ /**
676
+ * Publish the queued signals whose sequences still carry the row they were
677
+ * drained for, and return the ones that could not be verified.
678
+ */
679
+ async function publishVerifiedSignals(db, candidates) {
680
+ const p = placeholders(db);
681
+ const rows = getQueryRows(await db.query(`SELECT seq, table_name, row_id FROM ${CHANGE_FEED_TABLE} WHERE seq IN (${candidates.map((_, index) => p(index + 1)).join(", ")})`, ...candidates.map((signal) => signal.seq)));
682
+ const identity = (table, rowId) => `${table}\u0000${rowId ?? ""}`;
683
+ const committed = new Map(rows.map((row) => [toSeqNumber(row.seq), identity(String(row.table_name ?? ""), row.row_id == null ? null : String(row.row_id))]));
684
+ const verified = candidates.filter((signal) => committed.get(signal.seq) === identity(signal.table, signal.rowId));
685
+ if (verified.length === 0) return candidates;
686
+ const publishedSeqs = new Set(verified.map((signal) => signal.seq));
687
+ publishSignals(db, verified);
688
+ return candidates.filter((signal) => !publishedSeqs.has(signal.seq));
689
+ }
690
+ /** Put unpublished signals back, ahead of anything queued meanwhile. */
691
+ function requeueDeferredSignals(dbKey, signals) {
692
+ if (signals.length === 0) return;
693
+ const queuedSince = deferredSignals.get(dbKey) ?? [];
694
+ const merged = [...signals, ...queuedSince];
695
+ deferredSignals.set(dbKey, merged.length > MAX_DEFERRED_SIGNALS ? merged.slice(merged.length - MAX_DEFERRED_SIGNALS) : merged);
696
+ }
697
+ /**
698
+ * Whether the drain that just ran has committed.
699
+ *
700
+ * Under autocommit the drain was its own transaction and ended with its
701
+ * statement, so no transaction id is assigned by the time this separate
702
+ * statement runs. Inside a caller transaction the id the drain assigned is
703
+ * still live and its rows are still uncommitted.
704
+ */
705
+ async function drainCommitted(db) {
706
+ const committed = getQueryRows(await db.query("SELECT pg_current_xact_id_if_assigned() IS NULL AS committed"))[0]?.committed;
707
+ return committed === true || committed === "t";
708
+ }
709
+ /**
710
+ * Drain without ever failing the caller — the read-path and prune policy.
711
+ *
712
+ * Any sequences it allocates are recorded on the handle's uncommitted-drain
713
+ * mark; see {@link uncommittedDrainMarks} for why a reader must not serve at
714
+ * or above it while the caller's transaction is still open.
715
+ */
716
+ async function drainChangeFeedBestEffort(db) {
717
+ try {
718
+ await drainChangeFeedDetailed(db);
719
+ } catch (error) {
720
+ warnDrainFailureOnce(db, error);
721
+ }
722
+ }
723
+ /**
315
724
  * Manual bump escape hatch for out-of-band writers.
316
725
  *
317
726
  * Framework mutation paths feed the log automatically, but raw SQL issued
@@ -370,11 +779,17 @@ async function getChangesSince(db, options) {
370
779
  const { since } = options;
371
780
  if (!Number.isFinite(since) || since < 0) throw new Error(`getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`);
372
781
  const limit = Math.min(Math.max(Math.floor(options.limit ?? 500), 1), MAX_CHANGES_LIMIT);
782
+ await drainChangeFeedBestEffort(db);
373
783
  const p = placeholders(db);
374
- const boundsRows = getQueryRows(await db.query(`SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`));
784
+ const boundsRows = getQueryRows(await db.query(getEngine(db) === "postgres" ? `SELECT MIN(seq) AS floor, MAX(seq) AS horizon, pg_current_xact_id_if_assigned() IS NOT NULL AS in_transaction FROM ${CHANGE_FEED_TABLE}` : `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`));
375
785
  const floor = toSeqNumber(boundsRows[0]?.floor);
376
786
  const horizon = toSeqNumber(boundsRows[0]?.horizon);
377
- if (horizon === 0) return since === 0 ? {
787
+ const inCallerTransaction = boundsRows[0]?.in_transaction === true || boundsRows[0]?.in_transaction === "t";
788
+ const handleKey = db;
789
+ if (!inCallerTransaction) uncommittedDrainMarks.delete(handleKey);
790
+ const drainMark = inCallerTransaction ? uncommittedDrainMarks.get(handleKey) : void 0;
791
+ const servedHorizon = drainMark === void 0 ? horizon : Math.min(horizon, drainMark - 1);
792
+ if (servedHorizon === 0) return since === 0 ? {
378
793
  changes: [],
379
794
  cursor: 0
380
795
  } : {
@@ -383,19 +798,19 @@ async function getChangesSince(db, options) {
383
798
  resyncRequired: true,
384
799
  resyncCursor: 0
385
800
  };
386
- if (since > horizon) return {
801
+ if (since > servedHorizon) return {
387
802
  changes: [],
388
803
  cursor: since,
389
804
  resyncRequired: true,
390
- resyncCursor: horizon
805
+ resyncCursor: servedHorizon
391
806
  };
392
807
  if (since < floor - 1) return {
393
808
  changes: [],
394
809
  cursor: since,
395
810
  resyncRequired: true,
396
- resyncCursor: horizon
811
+ resyncCursor: servedHorizon
397
812
  };
398
- if (horizon === since) return {
813
+ if (servedHorizon <= since) return {
399
814
  changes: [],
400
815
  cursor: since
401
816
  };
@@ -406,7 +821,7 @@ async function getChangesSince(db, options) {
406
821
  conditions.push(`seq > ${next()}`);
407
822
  params.push(since);
408
823
  conditions.push(`seq <= ${next()}`);
409
- params.push(horizon);
824
+ params.push(servedHorizon);
410
825
  const tables = options.tables?.filter((table) => table.trim().length > 0);
411
826
  if (tables && tables.length > 0) {
412
827
  conditions.push(`table_name IN (${tables.map(() => next()).join(", ")})`);
@@ -422,7 +837,7 @@ async function getChangesSince(db, options) {
422
837
  const changes = getQueryRows(await db.query(sql, ...params)).map(rowToEntry);
423
838
  return {
424
839
  changes,
425
- cursor: changes.length === limit ? changes[changes.length - 1].seq : horizon
840
+ cursor: changes.length === limit ? changes[changes.length - 1].seq : servedHorizon
426
841
  };
427
842
  }
428
843
  /**
@@ -493,9 +908,17 @@ async function getTableVersion(db, table) {
493
908
  if (!name) throw new Error("getTableVersion requires a non-empty table name");
494
909
  await ensureChangeFeedTable(db);
495
910
  const p = placeholders(db);
496
- const tableVersion = getQueryRows(await db.query(`SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`, name))[0]?.version;
497
- if (tableVersion != null) return toSeqNumber(tableVersion);
498
- return toSeqNumber(getQueryRows(await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`))[0]?.horizon);
911
+ const row = getQueryRows(await db.query(getEngine(db) === "postgres" ? `SELECT
912
+ (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,
913
+ (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,
914
+ (SELECT COUNT(*) FROM ${POSTGRES_CHANGE_FEED_PENDING_TABLE} WHERE table_name = ${p(1)}) AS staged` : `SELECT
915
+ (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,
916
+ (SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,
917
+ 0 AS staged`, name))[0];
918
+ const staged = toSeqNumber(row?.staged);
919
+ const tableVersion = row?.version;
920
+ if (tableVersion != null) return toSeqNumber(tableVersion) + staged;
921
+ return toSeqNumber(row?.horizon) + staged;
499
922
  }
500
923
  function toSeqNumber(value) {
501
924
  return toSafeInteger(value ?? 0, "Change-feed sequence");
@@ -543,6 +966,7 @@ async function pruneChangeFeed(db, retention) {
543
966
  if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);
544
967
  if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);
545
968
  const p = placeholders(db);
969
+ await drainChangeFeedBestEffort(db);
546
970
  const horizon = toSeqNumber(getQueryRows(await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`))[0]?.horizon);
547
971
  if (horizon === 0) return { pruned: 0 };
548
972
  let pruned = 0;
@@ -556,11 +980,9 @@ async function pruneChangeFeed(db, retention) {
556
980
  }
557
981
  if (maxAgeMs != null) {
558
982
  const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
559
- pruned += await deleteCounted(db, `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`, [
560
- cutoff,
561
- horizon,
562
- prunedThrough
563
- ], dryRun);
983
+ const firstRetained = toSeqNumber(getQueryRows(await db.query(`SELECT MIN(seq) AS first_retained FROM ${CHANGE_FEED_TABLE} WHERE created_at >= ${p(1)}`, cutoff))[0]?.first_retained);
984
+ const ageThrough = Math.min(firstRetained > 0 ? firstRetained - 1 : horizon - 1, horizon - 1);
985
+ if (ageThrough > prunedThrough) pruned += await deleteCounted(db, `seq <= ${p(1)} AND seq > ${p(2)}`, [ageThrough, prunedThrough], dryRun);
564
986
  }
565
987
  return { pruned };
566
988
  }
@@ -574,6 +996,8 @@ var CHANGE_FEED_WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
574
996
  var warnedAppendFailures = /* @__PURE__ */ new Set();
575
997
  /** Databases we already warned about after a failed signal publish (#1763). */
576
998
  var warnedSignalPublishFailures = /* @__PURE__ */ new Set();
999
+ /** Databases we already warned about after a failed feed drain (#2649). */
1000
+ var warnedDrainFailures = /* @__PURE__ */ new Set();
577
1001
  /**
578
1002
  * Register the change-feed writer with {@link GlobalInterceptors}.
579
1003
  *
@@ -641,6 +1065,7 @@ async function appendForInstance(instance, operation) {
641
1065
  operation,
642
1066
  tenantId: rowTenantId
643
1067
  });
1068
+ if (seq == null) return;
644
1069
  try {
645
1070
  publishChangeSignal(db, {
646
1071
  table,
@@ -668,6 +1093,14 @@ function warnAppendFailureOnce(db, table, error) {
668
1093
  logger.warn(`Change feed: failed to append a change entry for '${table}'. The write itself succeeded; the feed is missing this change (further failures for this database are suppressed). Consumers recover on full resync.`, { error: error instanceof Error ? error.message : String(error) });
669
1094
  } catch {}
670
1095
  }
1096
+ function warnDrainFailureOnce(db, error) {
1097
+ try {
1098
+ const dbKey = resolveDbCacheKey(db);
1099
+ if (warnedDrainFailures.has(dbKey)) return;
1100
+ warnedDrainFailures.add(dbKey);
1101
+ logger.warn("Change feed: failed to sequence entries staged inside caller transactions. Those entries are durable but stay invisible to cursor readers until a drain succeeds (further failures for this database are suppressed). A read-only handle cannot drain — schedule drainChangeFeed() on a writable connection.", { error: error instanceof Error ? error.message : String(error) });
1102
+ } catch {}
1103
+ }
671
1104
  function warnSignalPublishFailureOnce(db, table, error) {
672
1105
  try {
673
1106
  const dbKey = resolveDbCacheKey(db);
@@ -682,8 +1115,12 @@ function warnSignalPublishFailureOnce(db, table, error) {
682
1115
  function resetChangeFeedWarnings() {
683
1116
  warnedAppendFailures.clear();
684
1117
  warnedSignalPublishFailures.clear();
1118
+ warnedDrainFailures.clear();
1119
+ lastAppendDrain.clear();
1120
+ stagedSinceLastDrain.clear();
1121
+ deferredSignals.clear();
685
1122
  }
686
1123
  //#endregion
687
- export { CHANGE_FEED_EXCLUDED_TABLES, CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, CHANGE_FEED_WAS_PERSISTED_KEY, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, getChangesSince, getTableVersion, getTenantScopedChangesSince, isChangeFeedObservableTable, pruneChangeFeed, recordInstanceChange, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
1124
+ export { CHANGE_FEED_EXCLUDED_TABLES, CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, CHANGE_FEED_WAS_PERSISTED_KEY, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, drainChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, ensurePostgresChangeFeedHelpers, getChangesSince, getTableVersion, getTenantScopedChangesSince, isChangeFeedObservableTable, pruneChangeFeed, recordInstanceChange, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
688
1125
 
689
1126
  //# sourceMappingURL=change-feed.js.map