@happyvertical/smrt-core 0.43.9 → 0.44.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 (72) hide show
  1. package/AGENTS.md +24 -1
  2. package/agents/generators.md +78 -5
  3. package/agents/schema-paths.md +99 -0
  4. package/dist/change-feed.d.ts +4 -0
  5. package/dist/change-feed.d.ts.map +1 -1
  6. package/dist/change-feed.js +8 -4
  7. package/dist/change-feed.js.map +1 -1
  8. package/dist/collection.d.ts +10 -0
  9. package/dist/collection.d.ts.map +1 -1
  10. package/dist/collection.js +145 -18
  11. package/dist/collection.js.map +1 -1
  12. package/dist/embedded-write-queue.d.ts +17 -29
  13. package/dist/embedded-write-queue.d.ts.map +1 -1
  14. package/dist/embedded-write-queue.js +44 -6
  15. package/dist/embedded-write-queue.js.map +1 -1
  16. package/dist/generators/custom-action.d.ts.map +1 -1
  17. package/dist/generators/custom-action.js +1 -1
  18. package/dist/generators/custom-action.js.map +1 -1
  19. package/dist/generators/mcp-emit.d.ts +6 -7
  20. package/dist/generators/mcp-emit.d.ts.map +1 -1
  21. package/dist/generators/mcp-emit.js +17 -29
  22. package/dist/generators/mcp-emit.js.map +1 -1
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/knowledge.d.ts +17 -1
  27. package/dist/knowledge.d.ts.map +1 -1
  28. package/dist/knowledge.js +33 -3
  29. package/dist/knowledge.js.map +1 -1
  30. package/dist/manifest/static-manifest.d.ts.map +1 -1
  31. package/dist/manifest/static-manifest.js +30 -2
  32. package/dist/manifest/static-manifest.js.map +1 -1
  33. package/dist/manifest/store.js +1 -1
  34. package/dist/manifest/store.js.map +1 -1
  35. package/dist/manifest.json +36 -2
  36. package/dist/migrations/differ.d.ts +49 -0
  37. package/dist/migrations/differ.d.ts.map +1 -1
  38. package/dist/migrations/differ.js +220 -4
  39. package/dist/migrations/differ.js.map +1 -1
  40. package/dist/migrations/orchestrate.d.ts +23 -0
  41. package/dist/migrations/orchestrate.d.ts.map +1 -1
  42. package/dist/migrations/orchestrate.js +12 -3
  43. package/dist/migrations/orchestrate.js.map +1 -1
  44. package/dist/object.d.ts +44 -2
  45. package/dist/object.d.ts.map +1 -1
  46. package/dist/object.js +207 -18
  47. package/dist/object.js.map +1 -1
  48. package/dist/registry/types.d.ts +8 -3
  49. package/dist/registry/types.d.ts.map +1 -1
  50. package/dist/schema/foreign-key-ddl.d.ts +7 -0
  51. package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
  52. package/dist/schema/foreign-key-ddl.js +7 -1
  53. package/dist/schema/foreign-key-ddl.js.map +1 -1
  54. package/dist/schema/schema-manager.d.ts +15 -0
  55. package/dist/schema/schema-manager.d.ts.map +1 -1
  56. package/dist/schema/schema-manager.js +52 -9
  57. package/dist/schema/schema-manager.js.map +1 -1
  58. package/dist/schema/types.d.ts +10 -0
  59. package/dist/schema/types.d.ts.map +1 -1
  60. package/dist/schema/uuid-convergence.d.ts +142 -0
  61. package/dist/schema/uuid-convergence.d.ts.map +1 -0
  62. package/dist/schema/uuid-convergence.js +0 -0
  63. package/dist/schema/uuid-convergence.js.map +1 -0
  64. package/dist/smrt-knowledge.json +33 -11
  65. package/dist/utils/scanner-module.d.ts +44 -0
  66. package/dist/utils/scanner-module.d.ts.map +1 -1
  67. package/dist/vite-plugin/index.d.ts +14 -1
  68. package/dist/vite-plugin/index.d.ts.map +1 -1
  69. package/dist/vite-plugin/index.js +54 -2
  70. package/dist/vite-plugin/index.js.map +1 -1
  71. package/dist/vite-plugin.js +3 -2
  72. package/package.json +6 -5
package/AGENTS.md CHANGED
@@ -27,6 +27,20 @@ subsystem you are editing. This file keeps what holds across all of them.
27
27
 
28
28
  - `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided
29
29
  - `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)
30
+ - Persisted `save()` calls use the object's loaded `updated_at` revision in the
31
+ database `UPDATE`; zero affected rows throws `RUNTIME_REVISION_CONFLICT`
32
+ without overwriting the newer row. `save({ expectedUpdatedAt })` supplies an
33
+ explicit revision when a caller binds the mutation to an earlier preview or
34
+ selection snapshot. Embedded adapters serialize every same-process model
35
+ save, delete, and complete `SmrtObject.withTransaction()` callback through
36
+ one queue; bound saves re-enter that hold. Custom write paths must use those
37
+ public APIs rather than bypassing the CAS ordering contract.
38
+ - Native DuckDB UUID columns are hydrated as canonical strings before model
39
+ initialization, natural-key lookup, and embedded revision claims. Exact
40
+ natural-key probes retain the interceptor-authorized filter when
41
+ canonicalizing a wrapped identity. Custom embedded-CAS paths that consume
42
+ persisted rows must use `getCanonicalPersistedRow()` so UUID identities are
43
+ cast in the same coherent read before reuse.
30
44
  - `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a "content body" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)
31
45
  - `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`
32
46
  - `getSlug()`: auto-generates from name → title → label → id
@@ -59,6 +73,15 @@ an `initialize()` hook may query through the same transaction-bound PostgreSQL
59
73
  client. Keep this serialization invariant; use `select` when callers need plain
60
74
  rows without model hydration.
61
75
 
76
+ Native DuckDB model hydration casts declared UUID columns to `VARCHAR` in the
77
+ read query because its JavaScript binding otherwise returns lossy HUGEINT
78
+ wrapper objects. Explicit projections apply the same cast for selected UUID
79
+ fields so bounded query envelopes preserve canonical row and relationship ids.
80
+ For STI child columns, raw `query()` SELECTs, and latest-related projections,
81
+ the read path describes the output types without evaluating the query, then
82
+ performs one data-bearing SELECT with UUID result columns cast to `VARCHAR`;
83
+ mutation statements are never reinterpreted or replayed.
84
+
62
85
  **WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.
63
86
  Arrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`
64
87
  renders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.
@@ -259,7 +282,7 @@ use this option to hide an otherwise invalid schema.
259
282
  ## Gotchas
260
283
 
261
284
  - **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.
262
- - **Transaction-bound instances**: `SmrtClass.withDatabase(db, callback)` temporarily binds an initialized instance (including its public `options.db`) to a supplied transaction database and restores the original binding on success or failure. Use it when domain validation and persistence must share one transaction; never reach into `_db`, and do not use the same instance concurrently during the callback.
285
+ - **Transaction-bound instances**: `SmrtClass.withDatabase(db, callback)` temporarily binds an initialized instance (including its public `options.db`) to a caller-owned transaction database and restores only the database binding. Transaction owners persisting one object should use `SmrtObject.withTransaction(callback)`, which restores identity/revision metadata after rollback and serializes its embedded callback with ordinary writes. Bound saves re-enter that hold. Never reach into `_db`, and do not use the same instance concurrently during either callback.
263
286
  - **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`
264
287
  - **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)
265
288
  - **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime verification is `tableExists()` only (`src/schema/table-verifier.ts`) — no column, type, or index check
@@ -45,11 +45,14 @@ extension decides what is written (#2279). `.ts`/`.mts` targets keep the source
45
45
  verbatim for `tsx` or Node type stripping — which is why the generated source
46
46
  must stay erasable-syntax-only (no parameter properties, enums, or namespaces).
47
47
  Every other target (`.smrt/mcp-server/index.js` by default) is transpiled to
48
- JavaScript with the `typescript` dependency before writing, because the printed
49
- run script and the generated `claude-config.example.json` both invoke it with
50
- plain `node`. A `.cjs`/`.cts` target is rejected outright: generated servers are
51
- ES modules. `src/generators/mcp-emit.ts` owns those decisions — do not
52
- reintroduce a bare `writeFile` of generated source.
48
+ JavaScript with lazily loaded `oxc-transform` before writing, because the
49
+ printed run script and the generated `claude-config.example.json` both invoke
50
+ it with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore
51
+ do not load OXC's native bindings.
52
+ This keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP
53
+ source must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected
54
+ outright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns
55
+ those decisions — do not reintroduce a bare `writeFile` of generated source.
53
56
 
54
57
  Modular output writes `config`, `tools/index`, and `handlers/index` with the
55
58
  entry point's own extension, and emits the entry's relative import specifiers
@@ -60,6 +63,76 @@ requested path rather than a hardcoded `index.js`.
60
63
  Generated code also has to be valid in an ES module: `arguments` is not a legal
61
64
  binding name there, however convenient it reads.
62
65
 
66
+ ## Emitted agent surface (#2591)
67
+
68
+ Generated model tools have always been build-time artifacts — virtual module,
69
+ manifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed
70
+ only once something mounted, so "what can an agent do in this app" had no answer
71
+ short of enumerating every route. This closes that.
72
+
73
+ The same OXC scan that builds the manifest also runs the scanner's
74
+ agent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it
75
+ in `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to
76
+ `buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration
77
+ discovery is NOT bound to the plugin's `include` glob — an app that scans
78
+ `src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`
79
+ sidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth
80
+ holding onto:
81
+
82
+ - **It never touches `manifest.json`.** The runtime manifest stays
83
+ runtime-focused; the agent-addressable surface is an agent/developer contract,
84
+ so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`
85
+ only, under `agentSurface: { intents, playbooks, diagnostics }`.
86
+ - **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a
87
+ native parser binary and `smrt-core`'s main entry is browser-reachable, so
88
+ core's sync knowledge builder must not import it. The Vite plugin already
89
+ imports the scanner lazily on the Node side and is the only caller that writes
90
+ this artifact.
91
+
92
+ The field is **omitted entirely** when a package declares nothing, which is what
93
+ makes it additive in practice rather than only on paper: every existing
94
+ package's checked-in artifact stays byte-identical.
95
+
96
+ Each declaring module gets a `sourceHashes` entry under the
97
+ `agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so
98
+ EDITING an intent sidecar marks the artifact stale exactly like editing
99
+ `AGENTS.md` does (`stale-domain-knowledge`).
100
+
101
+ Hashes alone cannot see an **added** declaration, though: a brand-new sidecar
102
+ has no recorded hash to mismatch, the runtime manifest never carries intents,
103
+ and `AGENTS.md` is untouched — so every other signal stays green while the
104
+ artifact omits a real operation. `dev:knowledge-check` therefore also re-derives
105
+ the declaration SET from source and compares it to the artifact by identity,
106
+ reporting either direction as `stale-agent-surface`. The scan is bounded like
107
+ the numeric-precision lint: `src` only, behind the scanner's token pre-filter.
108
+
109
+ That re-derivation must model what the EMITTER sees, not merely what is on
110
+ disk, or it reports drift no rebuild can clear. Which files count is decided by
111
+ the scanner's exported `isAgentSurfaceSourcePath` — the same predicate the
112
+ emitter itself uses, never a list copied into the checker — and the per-file
113
+ results run through `mergeAgentSurfaces` before comparing, because the merge is
114
+ where a duplicate identity and a derived tool-name collision are resolved and
115
+ the artifact is the merged result.
116
+ Diagnostics are compared alongside identities: a sidecar containing only a
117
+ computed declaration adds no identity and has no prior hash, so without that,
118
+ "a diagnostic, never silence" would quietly become "a diagnostic, until the
119
+ artifact goes stale". The walk covers `<pkg>/src` while the emitter globs the
120
+ whole project root, so an emitted entry from outside `src` is not reported as
121
+ missing — this check did not look there, and claiming otherwise would be an
122
+ error nothing could clear.
123
+
124
+ Both `stale-*` codes are warnings by default and errors under `--strict`, which
125
+ is what CI runs. Alongside them: `agent-surface-missing-identity`,
126
+ `agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are
127
+ errors, and `agent-surface-not-static` is a warning. A cross-file duplicate
128
+ arrives as a *diagnostic* rather than two entries — the scanner's merge already
129
+ dropped the loser — so that diagnostic maps to the duplicate error rather than
130
+ the not-static warning; otherwise the error would be unreachable for the case it
131
+ exists to catch.
132
+
133
+ `smrt doctor` prints the whole surface — model tools, intents, playbooks — from
134
+ these artifacts alone, with no application running.
135
+
63
136
  ## Custom-action contract
64
137
 
65
138
  `resolveCustomActionMetadata()` is the common discovery and invocation contract
@@ -715,6 +715,105 @@ SQLite requires a deliberate table rebuild; DuckDB reports the unsupported ALTER
715
715
  path. Neither engine treats an unsupported constraint addition as a successful
716
716
  no-op.
717
717
 
718
+ ### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)
719
+
720
+ R11 made SMRT identifiers and references native `uuid` on PostgreSQL. A
721
+ database created before that change still stores its `id` columns as `text`
722
+ while every reference column added afterwards materializes as `uuid`.
723
+ PostgreSQL cannot implement a foreign key across two different physical types —
724
+ FK DDL admits no cast — so `ADD CONSTRAINT … NOT VALID` fails with SQLSTATE
725
+ 42804 and aborts every later statement in the same migration batch.
726
+
727
+ Two rails handle it, and both are PostgreSQL-only. SQLite stores UUIDs as text
728
+ by design and DuckDB cannot rewrite a column type in place, so neither engine
729
+ emits anything for this drift.
730
+
731
+ **The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`
732
+ reads both live column types and refuses to emit `ADD CONSTRAINT` when they
733
+ disagree, naming both columns, both live types, and the repair. It deliberately
734
+ skips the orphan probe in that case: across mismatched types the probe answers
735
+ a question about casted values, not about the constraint being refused, and it
736
+ has to run again after the columns converge anyway.
737
+
738
+ **The differ converges the columns.** `planUuidConvergence()`
739
+ (`src/schema/uuid-convergence.ts`) groups every manifest relationship that
740
+ declares UUID on both sides into connected components and converges a component
741
+ only when the live database already proves the target shape — at least one
742
+ member is native `uuid`. A component that is `text` on *every* side is the
743
+ tolerated pre-R11 deployment and is left alone; its foreign keys are
744
+ type-compatible today, and the R11 uuid/text equivalence in
745
+ `migrations/differ.ts` keeps it out of the column diff.
746
+
747
+ Components, not individual pairs, are the unit of decision: one legacy `text`
748
+ primary key can be referenced by several children, and converting it for one
749
+ of them would break every sibling that is still `text`. A self-referential
750
+ table falls out of the same grouping because both endpoints land in one
751
+ component. Convergence is relationship-driven, so a legacy `text` id that
752
+ nothing references keeps its R11 tolerance.
753
+
754
+ The planner never coerces data. Before emitting anything it probes each column
755
+ it would rewrite for values that are not uuid-shaped (the same `~*` canonical
756
+ pattern the orphan probe uses) and refuses the whole component — with the count
757
+ and a sample value — if any exist, if the probe cannot run, if a member carries
758
+ some third physical type, or if a live foreign key still constrains a column
759
+ that must change. `@happyvertical/sql` introspection does not expose live
760
+ PostgreSQL constraint names, so SMRT cannot drop and re-add those constraints
761
+ for you: drop them deliberately, rerun the migration to converge, and let SMRT
762
+ re-add the manifest constraints.
763
+
764
+ Refusals are reported, not silent. Each one becomes a warning advisory with no
765
+ executable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and
766
+ `db:status` shows **blocked: incompatible column types** instead of *pending*.
767
+ The same check runs per relationship in `compareForeignKeys`, so a foreign key
768
+ whose live types will still disagree after this run's conversions is reported
769
+ blocked rather than emitted as pending DDL that cannot succeed.
770
+
771
+ The planner also inspects tables the manifest no longer declares. A live
772
+ foreign key from an orphan table onto a column that must convert still blocks
773
+ `ALTER COLUMN … TYPE`, so the differ introspects every existing table — not
774
+ only the manifest ones — whenever there is at least one conversion candidate,
775
+ and reports the dependency instead of emitting DDL PostgreSQL would reject. An
776
+ already-converged database has no candidates and pays nothing.
777
+
778
+ Ordering is a contract. Conversions carry `SchemaChange.phase =
779
+ 'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE
780
+ and every foreign-key statement in the batch**. Both halves matter:
781
+ `planForeignKeyCreation()` only defers the constraints inside a mutual cycle,
782
+ so an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*
783
+ — a brand-new `uuid` child pointing at a legacy `text` parent fails exactly
784
+ like an existing one, before the parent could be converted. Conversions only
785
+ ever rewrite columns that already exist, so leading the batch is always safe. A
786
+ live `DEFAULT` on a converting column is dropped first (PostgreSQL refuses
787
+ `ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default
788
+ comparison re-establishes the manifest default on the next run.
789
+
790
+ There are **two** batch builders and both order on that marker:
791
+ `collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by
792
+ `getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch
793
+ `db:migrate` assembles by hand in `@happyvertical/smrt-cli`
794
+ (`commands/utilities.ts`). `partitionSchemaChanges()` carries
795
+ `SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the
796
+ same way, in both the applied batch and the `--dry-run` preview. If you add a
797
+ third consumer, order it the same way.
798
+
799
+ Convergence entries carry the manifest column definition. Every `type_upgrade`
800
+ consumer reads `SchemaChange.column` — `partitionSchemaChanges()` in
801
+ `@happyvertical/smrt-cli` skips an entry without one — so a conversion missing
802
+ it would drop out of the `db:migrate` batch while `compareForeignKeys()` still
803
+ assumed the converged type. Refused convergences carry the same column plus an
804
+ advisory and no SQL, and the CLI routes them to the report-only advisories
805
+ rather than to manual interventions or the tracker.
806
+
807
+ The uuid wording is gated on the manifest. Both the runtime guard and the
808
+ status planner reach their incompatible-type branch for *any* mismatched pair,
809
+ not only uuid/text. A `USING …::uuid` repair is suggested only when the
810
+ manifest declares UUID on both sides **and** a live side is actually `text`;
811
+ otherwise the diagnostic names the two live types and asks the operator to
812
+ align them deliberately.
813
+
814
+ The conversion is one-time and idempotent: once the column is native `uuid`,
815
+ the component is uniformly UUID and the planner emits nothing.
816
+
718
817
  Properties to keep if you touch that module:
719
818
 
720
819
  - **The plan is registry-derived and rebuilt per delete.** Registration is
@@ -1,4 +1,5 @@
1
1
  import { DatabaseInterface } from '@happyvertical/sql';
2
+ import { SmrtObject } from './object.js';
2
3
  /** Name of the append-only change-feed system table. */
3
4
  export declare const CHANGE_FEED_TABLE = "_smrt_changes";
4
5
  /**
@@ -324,6 +325,7 @@ export declare function getTableVersion(db: DatabaseInterface, table: string): P
324
325
  export declare function pruneChangeFeed(db: DatabaseInterface, retention: ChangeFeedRetention): Promise<{
325
326
  pruned: number;
326
327
  }>;
328
+ export declare const CHANGE_FEED_WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
327
329
  /**
328
330
  * Register the change-feed writer with {@link GlobalInterceptors}.
329
331
  *
@@ -348,6 +350,8 @@ export declare function pruneChangeFeed(db: DatabaseInterface, retention: Change
348
350
  export declare function registerChangeFeedWriter(): void;
349
351
  /** Unregister the change-feed writer (test helper). */
350
352
  export declare function unregisterChangeFeedWriter(): boolean;
353
+ /** Record a framework-owned mutation that intentionally bypasses save hooks. */
354
+ export declare function recordInstanceChange(instance: SmrtObject, operation?: ChangeOperation): Promise<void>;
351
355
  /**
352
356
  * Reset the append-failure and signal-publish warning dedups (test helper).
353
357
  */
@@ -1 +1 @@
1
- {"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}
@@ -569,7 +569,7 @@ async function deleteCounted(db, condition, params, dryRun = false) {
569
569
  if (total > 0 && !dryRun) await db.query(`DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`, ...params);
570
570
  return total;
571
571
  }
572
- var WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
572
+ var CHANGE_FEED_WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
573
573
  /** Databases we already warned about after a failed feed append. */
574
574
  var warnedAppendFailures = /* @__PURE__ */ new Set();
575
575
  /** Databases we already warned about after a failed signal publish (#1763). */
@@ -604,12 +604,12 @@ function registerChangeFeedWriter() {
604
604
  try {
605
605
  context.metadata = {
606
606
  ...context.metadata,
607
- [WAS_PERSISTED_KEY]: instance.isPersisted === true
607
+ [CHANGE_FEED_WAS_PERSISTED_KEY]: instance.isPersisted === true
608
608
  };
609
609
  } catch {}
610
610
  },
611
611
  async afterSave(instance, context) {
612
- await appendForInstance(instance, context.metadata?.[WAS_PERSISTED_KEY] === true ? "update" : "create");
612
+ await appendForInstance(instance, context.metadata?.["_smrtChangeFeedWasPersisted"] === true ? "update" : "create");
613
613
  },
614
614
  async afterDelete(instance) {
615
615
  await appendForInstance(instance, "delete");
@@ -656,6 +656,10 @@ async function appendForInstance(instance, operation) {
656
656
  warnAppendFailureOnce(db, table, error);
657
657
  }
658
658
  }
659
+ /** Record a framework-owned mutation that intentionally bypasses save hooks. */
660
+ async function recordInstanceChange(instance, operation = "update") {
661
+ await appendForInstance(instance, operation);
662
+ }
659
663
  function warnAppendFailureOnce(db, table, error) {
660
664
  try {
661
665
  const dbKey = resolveDbCacheKey(db);
@@ -680,6 +684,6 @@ function resetChangeFeedWarnings() {
680
684
  warnedSignalPublishFailures.clear();
681
685
  }
682
686
  //#endregion
683
- export { CHANGE_FEED_EXCLUDED_TABLES, CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, getChangesSince, getTableVersion, getTenantScopedChangesSince, isChangeFeedObservableTable, pruneChangeFeed, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
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 };
684
688
 
685
689
  //# sourceMappingURL=change-feed.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"change-feed.js","names":[],"sources":["../src/change-feed.ts"],"sourcesContent":["/**\n * Adapter-agnostic change feed — the framework's change-observation spine\n * (issue #1758, parent PRD #1755).\n *\n * Every framework `save()`/`delete()` appends exactly one row to the\n * `_smrt_changes` system table (monotonic per-database sequence, table name,\n * row id, operation, tenant id, timestamp). Deletes are recorded as\n * tombstones (`operation: 'delete'`), distinguishable from updates. One read\n * interface — {@link getChangesSince} — returns changes after a cursor,\n * filterable by table and tenant, and serves three eventual consumers:\n * client delta pull, the SSE push channel, and the per-table version source\n * backing ETags.\n *\n * ## Cursor semantics (the precise guarantee)\n *\n * Sequences are allocated *inside* the append statement as\n * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on\n * primary-key conflict. `MAX(seq)` only observes committed rows, so a row\n * with sequence `N` can only be inserted while every row with sequence\n * `< N` is already committed (a conflicting in-flight allocation of the same\n * value blocks, then retries). Committed rows therefore always form a\n * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence\n * order equals commit order; out-of-order commit visibility (the classic\n * MVCC race that makes native identity/serial columns unsafe as cursors\n * under concurrent writers) cannot occur.\n *\n * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then\n * returns matching rows with `since < seq <= H` (bounded by `limit`), and a\n * `cursor` that is either `H` (page exhaustive) or the last returned `seq`\n * (page limited). Because no change can ever commit at or below an observed\n * horizon after it was observed, polling with returned cursors misses no\n * committed change and never returns the same change twice — under any\n * number of concurrent writers, identically on SQLite, Postgres and DuckDB.\n * This is the design reason the allocator is `MAX+1` rather than a native\n * AUTOINCREMENT/identity column: identity values are allocated before\n * commit, so a reader on Postgres could observe seq 101 while seq 100 is\n * still uncommitted and advance its cursor past it. (No shared\n * auto-increment mechanism exists in the system-table schema path either;\n * see `system/schema.ts`.)\n *\n * Contention note: appends serialize on the head of the log. Each append is\n * one small INSERT (issued from the write path *after* the user's row was\n * written), so the serialization window is one statement; conflicts resolve\n * with a bounded retry loop and are impossible on single-writer engines\n * (SQLite). PostgreSQL invokes that INSERT through the framework-owned\n * `_smrt_append_change` function so failure isolation remains one statement.\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. PostgreSQL runs the INSERT in a\n * PL/pgSQL exception subtransaction and returns a caught SQLSTATE as data;\n * JavaScript only throws/logs after PostgreSQL has restored the caller's\n * transaction, so a swallowed append failure cannot surface later as 25P02.\n * The append still joins a caller-managed transaction on the same handle and\n * shares its fate (a rollback removes the change row with the data row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` plus a fresh resume cursor instead of silently\n * skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { publishChangeSignal } from './change-signals.js';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport {\n isEmbeddedDatabase,\n withEmbeddedWriteQueue,\n} from './embedded-write-queue.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { SYSTEM_TABLE_NAMES } from './schema/system-table-shapes.js';\nimport {\n CREATE_SMRT_CHANGES_TABLE,\n ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n ENSURE_POSTGRES_CHANGE_FEED_SCHEMA,\n FRAMEWORK_OPERATIONAL_TABLES,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} from './system/schema.js';\nimport { toSafeInteger } from './utils/safe-integer.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Name of the append-only change-feed system table. */\nexport const CHANGE_FEED_TABLE = '_smrt_changes';\n\n/**\n * Tables the change-feed writer never records (issue #2376).\n *\n * The writer used to skip every table whose name started with `_smrt_`. That\n * prefix stopped meaning \"framework bookkeeping\" once packages began naming\n * their *domain* tables `_smrt_feature_overrides`, `_smrt_prompt_overrides`,\n * `_smrt_subscription_plans`, `_smrt_report_schedules`, `_smrt_field_policies`\n * and ~20 more: clients syncing those through `_changes` never saw an update.\n *\n * The exclusion is now an allowlist of tables the framework actually owns —\n * the hand-written bookkeeping DDL ({@link SYSTEM_TABLE_NAMES}, which includes\n * the feed's own table so it can never observe itself), the model-backed\n * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired\n * system tables that may still exist on older databases.\n */\nexport const CHANGE_FEED_EXCLUDED_TABLES: ReadonlySet<string> = new Set([\n ...SYSTEM_TABLE_NAMES,\n ...FRAMEWORK_OPERATIONAL_TABLES,\n ...RETIRED_SYSTEM_TABLES,\n]);\n\n/**\n * Whether framework writes to `tableName` are recorded in the change feed.\n *\n * Exported so tooling and tests can reason about feed coverage without\n * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.\n */\nexport function isChangeFeedObservableTable(tableName: string): boolean {\n return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);\n}\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync before resuming from {@link ChangeFeedPage.resyncCursor}.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged.\n * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n /**\n * Count the entries the bounds select without deleting them (#2375).\n *\n * Lets the retention sweep and `smrt db:prune --dry-run` preview the exact\n * same predicate the real prune would execute.\n */\n dryRun?: boolean;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const signals: string[] = [];\n const pending: unknown[] = [error];\n const seen = new Set<object>();\n\n while (pending.length > 0 && seen.size < 5) {\n const candidate = pending.shift();\n if (typeof candidate === 'string') {\n signals.push(candidate);\n continue;\n }\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {\n continue;\n }\n seen.add(candidate);\n\n const shaped = candidate as {\n cause?: unknown;\n code?: unknown;\n context?: unknown;\n message?: unknown;\n };\n if (typeof shaped.message === 'string') signals.push(shaped.message);\n if (typeof shaped.code === 'string') signals.push(shaped.code);\n if (shaped.cause !== undefined) pending.push(shaped.cause);\n\n if (shaped.context && typeof shaped.context === 'object') {\n const originalError = (shaped.context as { originalError?: unknown })\n .originalError;\n if (originalError !== undefined) pending.push(originalError);\n }\n }\n\n const message = signals.join(', ');\n return (\n /\\b23505\\b/.test(message) ||\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nasync function postgresChangeFeedAppendFunctionExists(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`,\n ),\n );\n return Boolean(rows[0]?.function_name);\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n createdAtType: string | null;\n}> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n to_regclass('${CHANGE_FEED_TABLE}') AS table_name,\n to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,\n (\n SELECT data_type\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name = '${CHANGE_FEED_TABLE}'\n AND column_name = 'created_at'\n ) AS created_at_type`,\n ),\n );\n return {\n tableExists: Boolean(rows[0]?.table_name),\n functionExists: Boolean(rows[0]?.function_name),\n createdAtType: rows[0]?.created_at_type\n ? String(rows[0].created_at_type)\n : null,\n };\n}\n\nfunction assertPostgresChangeFeedTimestampCurrent(\n state: Awaited<ReturnType<typeof getPostgresChangeFeedSchemaState>>,\n): void {\n if (state.createdAtType === 'timestamp without time zone') {\n throw new Error(\n 'Legacy _smrt_changes.created_at requires an explicit audited migratePostgresSystemTimestamps() call before change-feed initialization',\n );\n }\n}\n\n/**\n * Install/refresh the PostgreSQL exception-subtransaction append boundary.\n *\n * Framework bootstrap calls this while applying the system-schema version that\n * introduced the helper, so upgraded databases acquire it before the migration\n * is recorded. Raw-handle initialization passes `replaceExisting: false` so a\n * read route does not require function ownership when the installed helper is\n * already current. A missing helper is installed by one server-side statement\n * that locks and rechecks before DDL. Non-PostgreSQL adapters are a no-op.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedAppendFunction(\n db: DatabaseInterface,\n options: {\n replaceExisting?: boolean;\n typeHint?: string;\n } = {},\n): Promise<void> {\n if (getEngine(db, options.typeHint) !== 'postgres') return;\n\n assertPostgresChangeFeedTimestampCurrent(\n await getPostgresChangeFeedSchemaState(db),\n );\n\n if (options.replaceExisting === false) {\n if (await postgresChangeFeedAppendFunctionExists(db)) return;\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n return;\n }\n\n await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n}\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n if (getEngine(db) === 'postgres') {\n const state = await getPostgresChangeFeedSchemaState(db);\n assertPostgresChangeFeedTimestampCurrent(state);\n if (\n state.tableExists &&\n state.functionExists &&\n state.createdAtType === 'timestamp with time zone'\n ) {\n ensuredHandles.add(db);\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\n } else {\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the\n * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception\n * handler is a PostgreSQL subtransaction: a failed attempt is rolled back\n * before the function returns `{ error_code, error_message }`. This method\n * then throws in JavaScript, where the existing retry/swallow policy applies\n * without aborting a caller-managed transaction. Keeping isolation inside one\n * database statement also prevents concurrent work on the same transaction\n * handle from interleaving inside a manual SAVEPOINT scope.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<number> {\n const table = input.table?.trim();\n if (!table) {\n throw new Error('appendChange requires a non-empty table name');\n }\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChange operation must be one of create/update/delete, got '${String(\n input.operation,\n )}'`,\n );\n }\n\n const engine = getEngine(db);\n const p = placeholders(db);\n // The INSERT yields the ACTUAL sequence it allocated in the SAME statement\n // (directly via RETURNING on portable engines, through the function on\n // PostgreSQL). A separate follow-up `SELECT MAX(seq)` is racy under concurrent\n // appends (a peer can commit a higher seq in between), which would hand two\n // distinct changes the same SSE `id` and let a client's `Last-Event-ID`\n // overshoot a change it never received. The allocator stays `MAX+1` under the\n // unique-PK retry, so committed sequences remain contiguous (the cursor\n // guarantee — see module docs).\n const sql =\n engine === 'postgres'\n ? `SELECT allocated_seq, error_code, error_message FROM ` +\n `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(` +\n `${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`\n : `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE} RETURNING seq`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n // The append is a root-connection write; on embedded engines it goes\n // through the per-database write queue so it can never overlap a\n // null-aware upsert's second-connection transaction (#2360 — the pair\n // livelocked into SQLITE_BUSY under concurrent NULL-tenant creates).\n const rows = getQueryRows(\n await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () =>\n db.query(sql, ...params),\n ),\n );\n const row = rows[0];\n if (!row) {\n throw new Error('Change feed append returned no result row');\n }\n if (engine === 'postgres' && row.error_code != null) {\n const error = new Error(\n String(row.error_message || 'PostgreSQL change-feed append failed'),\n ) as Error & { code: string };\n error.code = String(row.error_code);\n throw error;\n }\n return toSeqNumber(engine === 'postgres' ? row.allocated_seq : row.seq);\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n\n // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\n}\n\nfunction toSeqNumber(value: unknown): number {\n // PostgreSQL surfaces BIGINT aggregates as strings and DuckDB can return\n // BigInt. Change-feed cursors must never advance on a rounded value.\n return toSafeInteger(value ?? 0, 'Change-feed sequence');\n}\n\nfunction rowToEntry(row: Record<string, unknown>): ChangeFeedEntry {\n return {\n seq: toSeqNumber(row.seq),\n table: String(row.table_name ?? ''),\n rowId: row.row_id == null ? null : String(row.row_id),\n operation: String(row.operation ?? 'update') as ChangeOperation,\n tenantId: row.tenant_id == null ? null : String(row.tenant_id),\n timestamp: normalizeTimestamp(row.created_at),\n };\n}\n\nfunction normalizeTimestamp(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n return String(value ?? '');\n}\n\n// ============================================================================\n// Retention / compaction\n// ============================================================================\n\n/**\n * Prune the change feed to bound its growth.\n *\n * Applies whichever bounds are provided (at least one is required):\n * - `maxRows`: keep only the newest N entries by sequence.\n * - `maxAgeMs`: drop entries older than the cutoff.\n *\n * Pruning deletes oldest-first, never renumbers surviving entries, and\n * **always retains the newest entry** (a non-empty feed is never emptied,\n * whatever the bounds say). That invariant anchors pruned-cursor detection:\n * retained sequences stay a contiguous run `[floor..horizon]`, so\n * {@link getChangesSince} can prove a cursor predates the retained window\n * (`resyncRequired`) — and a fully caught-up consumer keeps polling\n * normally even after everything older was pruned.\n *\n * Cursors within the retained window keep working. Schedule pruning (e.g.\n * via `@happyvertical/smrt-jobs`) with a retention window comfortably\n * larger than the slowest consumer's polling interval; consumers whose\n * cursor falls out of it are told to full-resync via `resyncRequired`.\n *\n * @returns The number of entries pruned — or, with `dryRun`, the number the\n * same bounds would prune (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows, dryRun = false } = retention;\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneChangeFeed requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);\n }\n\n const p = placeholders(db);\n\n // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n let prunedThrough = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n prunedThrough = pruneThrough;\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)}`,\n [pruneThrough],\n dryRun,\n );\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n // `seq > prunedThrough` excludes what the row bound already accounted for.\n // Redundant when the rows were really deleted, load-bearing under\n // `dryRun`, where nothing was — without it overlapping entries would be\n // counted by both bounds.\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`,\n [cutoff, horizon, prunedThrough],\n dryRun,\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n dryRun = false,\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0 && !dryRun) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nconst WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. Framework-owned tables listed in\n * {@link CHANGE_FEED_EXCLUDED_TABLES} are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted = context.metadata?.[WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // Framework-owned tables are bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself. The\n // test is an allowlist, not the `_smrt_` prefix: ~25 domain tables carry\n // that prefix and must be observed (issue #2376).\n if (!isChangeFeedObservableTable(table)) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\n }\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"change-feed.js","names":[],"sources":["../src/change-feed.ts"],"sourcesContent":["/**\n * Adapter-agnostic change feed — the framework's change-observation spine\n * (issue #1758, parent PRD #1755).\n *\n * Every framework `save()`/`delete()` appends exactly one row to the\n * `_smrt_changes` system table (monotonic per-database sequence, table name,\n * row id, operation, tenant id, timestamp). Deletes are recorded as\n * tombstones (`operation: 'delete'`), distinguishable from updates. One read\n * interface — {@link getChangesSince} — returns changes after a cursor,\n * filterable by table and tenant, and serves three eventual consumers:\n * client delta pull, the SSE push channel, and the per-table version source\n * backing ETags.\n *\n * ## Cursor semantics (the precise guarantee)\n *\n * Sequences are allocated *inside* the append statement as\n * `COALESCE(MAX(seq), 0) + 1` over the feed table itself, with a retry on\n * primary-key conflict. `MAX(seq)` only observes committed rows, so a row\n * with sequence `N` can only be inserted while every row with sequence\n * `< N` is already committed (a conflicting in-flight allocation of the same\n * value blocks, then retries). Committed rows therefore always form a\n * contiguous run ending at `MAX(seq)` — the **committed horizon**. Sequence\n * order equals commit order; out-of-order commit visibility (the classic\n * MVCC race that makes native identity/serial columns unsafe as cursors\n * under concurrent writers) cannot occur.\n *\n * {@link getChangesSince} reads the committed horizon `H = MAX(seq)`, then\n * returns matching rows with `since < seq <= H` (bounded by `limit`), and a\n * `cursor` that is either `H` (page exhaustive) or the last returned `seq`\n * (page limited). Because no change can ever commit at or below an observed\n * horizon after it was observed, polling with returned cursors misses no\n * committed change and never returns the same change twice — under any\n * number of concurrent writers, identically on SQLite, Postgres and DuckDB.\n * This is the design reason the allocator is `MAX+1` rather than a native\n * AUTOINCREMENT/identity column: identity values are allocated before\n * commit, so a reader on Postgres could observe seq 101 while seq 100 is\n * still uncommitted and advance its cursor past it. (No shared\n * auto-increment mechanism exists in the system-table schema path either;\n * see `system/schema.ts`.)\n *\n * Contention note: appends serialize on the head of the log. Each append is\n * one small INSERT (issued from the write path *after* the user's row was\n * written), so the serialization window is one statement; conflicts resolve\n * with a bounded retry loop and are impossible on single-writer engines\n * (SQLite). PostgreSQL invokes that INSERT through the framework-owned\n * `_smrt_append_change` function so failure isolation remains one statement.\n *\n * ## Failure semantics\n *\n * A feed-write failure must never fail the user's write. The interceptor\n * wraps the append in a try/catch: on failure it logs a warning (deduped per\n * database) and continues. The trade-off is availability of the user's\n * write over completeness of the feed — consumers already need a\n * full-resync path for cursors older than the retention window, and the\n * same path covers a (rare) dropped feed row. PostgreSQL runs the INSERT in a\n * PL/pgSQL exception subtransaction and returns a caught SQLSTATE as data;\n * JavaScript only throws/logs after PostgreSQL has restored the caller's\n * transaction, so a swallowed append failure cannot surface later as 25P02.\n * The append still joins a caller-managed transaction on the same handle and\n * shares its fate (a rollback removes the change row with the data row).\n *\n * ## Known gaps (documented in the PRD)\n *\n * - Writes that bypass framework mutation paths (raw SQL) are invisible to\n * the feed — the same accepted gap as the #1499 collection cache.\n * {@link bumpChangeFeed} is the manual escape hatch: out-of-band writers\n * append a synthetic change row for the affected table.\n * - **Spurious `update` entries**: `SmrtObject.save()` has no dirty-check,\n * so a field-unchanged `.save()` still appends an `update` row. This is\n * by design — the writer observes writes, not diffs (it has no old-row\n * access), so the feed faithfully mirrors the write path. Diff-aware\n * paths (`getOrUpsert()`'s diff guard, the sync-apply endpoint's no-op\n * detection) short-circuit before `save()` and append nothing.\n * Subscribers must tolerate spurious entries; they are convergent — a\n * re-fetch returns identical data.\n *\n * ## Retention\n *\n * The log is append-only and grows with write volume. {@link pruneChangeFeed}\n * bounds it by age (`maxAgeMs`) and/or row count (`maxRows`); call it from a\n * scheduled job sized so the retention window comfortably exceeds the\n * slowest consumer's polling interval. Pruning deletes oldest-first and\n * always retains the newest entry, so retained sequences stay a contiguous\n * `[floor..horizon]` run — which is how {@link getChangesSince} *detects* a\n * consumer whose cursor predates the retained window and answers it with\n * `resyncRequired: true` plus a fresh resume cursor instead of silently\n * skipping the pruned changes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1758\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { publishChangeSignal } from './change-signals.js';\nimport { resolveDbCacheKey } from './collection-cache.js';\nimport { resolveDispatchTenantScope } from './dispatch/tenant-resolver.js';\nimport {\n isEmbeddedDatabase,\n withEmbeddedWriteQueue,\n} from './embedded-write-queue.js';\nimport { GlobalInterceptors, type InterceptorContext } from './interceptors.js';\nimport type { SmrtObject } from './object.js';\nimport { detectEngine } from './schema/ddl/index.js';\nimport { SYSTEM_TABLE_NAMES } from './schema/system-table-shapes.js';\nimport {\n CREATE_SMRT_CHANGES_TABLE,\n ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n ENSURE_POSTGRES_CHANGE_FEED_SCHEMA,\n FRAMEWORK_OPERATIONAL_TABLES,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME,\n REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n RETIRED_SYSTEM_TABLES,\n} from './system/schema.js';\nimport { toSafeInteger } from './utils/safe-integer.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Name of the append-only change-feed system table. */\nexport const CHANGE_FEED_TABLE = '_smrt_changes';\n\n/**\n * Tables the change-feed writer never records (issue #2376).\n *\n * The writer used to skip every table whose name started with `_smrt_`. That\n * prefix stopped meaning \"framework bookkeeping\" once packages began naming\n * their *domain* tables `_smrt_feature_overrides`, `_smrt_prompt_overrides`,\n * `_smrt_subscription_plans`, `_smrt_report_schedules`, `_smrt_field_policies`\n * and ~20 more: clients syncing those through `_changes` never saw an update.\n *\n * The exclusion is now an allowlist of tables the framework actually owns —\n * the hand-written bookkeeping DDL ({@link SYSTEM_TABLE_NAMES}, which includes\n * the feed's own table so it can never observe itself), the model-backed\n * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired\n * system tables that may still exist on older databases.\n */\nexport const CHANGE_FEED_EXCLUDED_TABLES: ReadonlySet<string> = new Set([\n ...SYSTEM_TABLE_NAMES,\n ...FRAMEWORK_OPERATIONAL_TABLES,\n ...RETIRED_SYSTEM_TABLES,\n]);\n\n/**\n * Whether framework writes to `tableName` are recorded in the change feed.\n *\n * Exported so tooling and tests can reason about feed coverage without\n * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.\n */\nexport function isChangeFeedObservableTable(tableName: string): boolean {\n return Boolean(tableName) && !CHANGE_FEED_EXCLUDED_TABLES.has(tableName);\n}\n\n/** Interceptor name of the framework's change-feed writer. */\nexport const CHANGE_FEED_INTERCEPTOR_NAME = 'smrt-change-feed';\n\n/**\n * Change operations recorded in the feed. Deletes are tombstones —\n * consumers can distinguish \"row changed\" from \"row is gone\" without\n * consulting the source table.\n */\nexport type ChangeOperation = 'create' | 'update' | 'delete';\n\n/** One entry of the change feed. */\nexport interface ChangeFeedEntry {\n /** Strictly monotonic per-database sequence (the cursor dimension). */\n seq: number;\n /** Physical table the change happened in (STI children report the shared base table). */\n table: string;\n /**\n * Primary key of the changed row, or `null` for table-level synthetic\n * bumps recorded via {@link bumpChangeFeed} without a row id.\n */\n rowId: string | null;\n /** What happened. `'delete'` entries double as tombstones. */\n operation: ChangeOperation;\n /** Tenant the changed row belongs to, or `null` for global/non-tenant rows. */\n tenantId: string | null;\n /** ISO-8601 timestamp recorded when the change was appended. */\n timestamp: string;\n}\n\n/** Options for {@link getChangesSince}. */\nexport interface GetChangesOptions {\n /**\n * Cursor to read after. Only rows with `seq` strictly greater than `since`\n * are returned; pass a previously returned {@link ChangeFeedPage.cursor} to\n * poll.\n *\n * `0` reads from the start of the log only while it has not been pruned past\n * the beginning. Once retention has raised the retained floor above the\n * start, `since: 0` (like any cursor older than the retained window) can no\n * longer be served incrementally — the read returns\n * {@link ChangeFeedPage.resyncRequired} and the caller must do a full\n * resync before resuming from {@link ChangeFeedPage.resyncCursor}.\n */\n since: number;\n /** Restrict to these physical table names. Empty/omitted → all tables. */\n tables?: string[];\n /**\n * Tenant visibility filter:\n * - omitted/`undefined` → no tenant filter (all rows).\n * - `null` → only global rows (`tenant_id IS NULL`).\n * - `'<tenantId>'` → that tenant's rows **plus** global rows, matching the\n * DispatchBus read rule (`tenant_id = T OR tenant_id IS NULL`). A tenant\n * never sees another tenant's changes.\n */\n tenantId?: string | null;\n /**\n * Page size (default {@link DEFAULT_CHANGES_LIMIT}, capped at\n * {@link MAX_CHANGES_LIMIT}). When a page fills up, the returned cursor\n * stops at the last returned row so the next poll continues seamlessly.\n */\n limit?: number;\n}\n\n/** Result page of {@link getChangesSince}. */\nexport interface ChangeFeedPage {\n /** Matching changes ordered by ascending `seq`. */\n changes: ChangeFeedEntry[];\n /**\n * The next cursor. Monotonic: never lower than the `since` it was derived\n * from. Equal to the committed horizon when the page was exhaustive, or to\n * the last returned `seq` when the page hit `limit`. Feed the value back\n * as `since` to observe every later change exactly once.\n */\n cursor: number;\n /**\n * Present (and `true`) when the supplied cursor cannot be served\n * incrementally and the consumer must fall back to a full resync:\n *\n * - the cursor predates the retained window (entries at or below it were\n * pruned away — the changes between it and the retained floor are gone\n * for good), or\n * - the cursor is ahead of the committed horizon / unknown to this\n * database (a foreign or reset cursor).\n *\n * When set, `changes` is empty and `cursor` echoes `since` unchanged.\n * After its full data refetch, the consumer should resume polling from\n * {@link resyncCursor}, the committed horizon observed by this read.\n * Detection is computed on the **unfiltered** log: `tables`/`tenantId`\n * filters legitimately hide rows and never trigger (or mask) a resync\n * signal.\n */\n resyncRequired?: boolean;\n /**\n * Current committed horizon to use after handling a resync. Present with\n * {@link resyncRequired}; separated from `cursor` so old callers that rely\n * on `cursor` echoing the rejected value keep their monotonic-cursor\n * invariant.\n */\n resyncCursor?: number;\n}\n\n/** Input for {@link appendChange} / {@link bumpChangeFeed}. */\nexport interface AppendChangeInput {\n /** Physical table name the change refers to. */\n table: string;\n /** Changed row's primary key; `null`/omitted records a table-level change. */\n rowId?: string | null;\n /** Operation to record (default `'update'`). */\n operation?: ChangeOperation;\n /** Tenant the change belongs to (default `null` = global). */\n tenantId?: string | null;\n}\n\n/** Retention bounds for {@link pruneChangeFeed}. At least one is required. */\nexport interface ChangeFeedRetention {\n /** Prune entries older than this many milliseconds. */\n maxAgeMs?: number;\n /** Keep at most this many newest entries (by sequence). */\n maxRows?: number;\n /**\n * Count the entries the bounds select without deleting them (#2375).\n *\n * Lets the retention sweep and `smrt db:prune --dry-run` preview the exact\n * same predicate the real prune would execute.\n */\n dryRun?: boolean;\n}\n\n/** Default page size for {@link getChangesSince}. */\nexport const DEFAULT_CHANGES_LIMIT = 500;\n\n/** Hard cap on the page size for {@link getChangesSince}. */\nexport const MAX_CHANGES_LIMIT = 5_000;\n\n/**\n * Maximum append attempts under sequence contention. Conflicts only occur\n * with concurrent writers on MVCC engines and resolve as soon as the\n * blocking transaction commits, so a small bound is ample.\n */\nconst MAX_APPEND_ATTEMPTS = 20;\n\nconst VALID_OPERATIONS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n]);\n\n// ============================================================================\n// Engine / SQL helpers (mirrors system/compatibility.ts conventions)\n// ============================================================================\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: { type?: string; url?: string };\n type?: string;\n};\n\nfunction getEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const withConfig = db as DatabaseWithConfig;\n return detectEngine(\n db.url || withConfig.config?.url || '',\n typeHint || withConfig.type || withConfig.config?.type,\n );\n}\n\n/**\n * Positional placeholder factory: Postgres uses `$n`, SQLite/DuckDB use `?`.\n */\nfunction placeholders(db: DatabaseInterface): (index: number) => string {\n const engine = getEngine(db);\n return engine === 'postgres' ? (index) => `$${index}` : () => '?';\n}\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n return [];\n}\n\nfunction isUniqueViolation(error: unknown): boolean {\n const signals: string[] = [];\n const pending: unknown[] = [error];\n const seen = new Set<object>();\n\n while (pending.length > 0 && seen.size < 5) {\n const candidate = pending.shift();\n if (typeof candidate === 'string') {\n signals.push(candidate);\n continue;\n }\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) {\n continue;\n }\n seen.add(candidate);\n\n const shaped = candidate as {\n cause?: unknown;\n code?: unknown;\n context?: unknown;\n message?: unknown;\n };\n if (typeof shaped.message === 'string') signals.push(shaped.message);\n if (typeof shaped.code === 'string') signals.push(shaped.code);\n if (shaped.cause !== undefined) pending.push(shaped.cause);\n\n if (shaped.context && typeof shaped.context === 'object') {\n const originalError = (shaped.context as { originalError?: unknown })\n .originalError;\n if (originalError !== undefined) pending.push(originalError);\n }\n }\n\n const message = signals.join(', ');\n return (\n /\\b23505\\b/.test(message) ||\n /unique constraint/i.test(message) ||\n /duplicate key/i.test(message) ||\n /primary key constraint/i.test(message) ||\n /constraint error/i.test(message)\n );\n}\n\n/**\n * Ensure the `_smrt_changes` system table exists on a database handle that\n * may not have passed through framework initialization (e.g. a raw handle\n * given to the REST generator). Idempotent (`CREATE ... IF NOT EXISTS`) and\n * guarded to run once per handle. Databases initialized through the\n * framework already have the table via the system-table bootstrap.\n */\nconst ensuredHandles = new WeakSet<object>();\n\nasync function postgresChangeFeedAppendFunctionExists(\n db: DatabaseInterface,\n): Promise<boolean> {\n const rows = getQueryRows(\n await db.query(\n `SELECT to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name`,\n ),\n );\n return Boolean(rows[0]?.function_name);\n}\n\nasync function getPostgresChangeFeedSchemaState(\n db: DatabaseInterface,\n): Promise<{\n tableExists: boolean;\n functionExists: boolean;\n createdAtType: string | null;\n}> {\n const rows = getQueryRows(\n await db.query(\n `SELECT\n to_regclass('${CHANGE_FEED_TABLE}') AS table_name,\n to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') AS function_name,\n (\n SELECT data_type\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name = '${CHANGE_FEED_TABLE}'\n AND column_name = 'created_at'\n ) AS created_at_type`,\n ),\n );\n return {\n tableExists: Boolean(rows[0]?.table_name),\n functionExists: Boolean(rows[0]?.function_name),\n createdAtType: rows[0]?.created_at_type\n ? String(rows[0].created_at_type)\n : null,\n };\n}\n\nfunction assertPostgresChangeFeedTimestampCurrent(\n state: Awaited<ReturnType<typeof getPostgresChangeFeedSchemaState>>,\n): void {\n if (state.createdAtType === 'timestamp without time zone') {\n throw new Error(\n 'Legacy _smrt_changes.created_at requires an explicit audited migratePostgresSystemTimestamps() call before change-feed initialization',\n );\n }\n}\n\n/**\n * Install/refresh the PostgreSQL exception-subtransaction append boundary.\n *\n * Framework bootstrap calls this while applying the system-schema version that\n * introduced the helper, so upgraded databases acquire it before the migration\n * is recorded. Raw-handle initialization passes `replaceExisting: false` so a\n * read route does not require function ownership when the installed helper is\n * already current. A missing helper is installed by one server-side statement\n * that locks and rechecks before DDL. Non-PostgreSQL adapters are a no-op.\n *\n * @internal\n */\nexport async function ensurePostgresChangeFeedAppendFunction(\n db: DatabaseInterface,\n options: {\n replaceExisting?: boolean;\n typeHint?: string;\n } = {},\n): Promise<void> {\n if (getEngine(db, options.typeHint) !== 'postgres') return;\n\n assertPostgresChangeFeedTimestampCurrent(\n await getPostgresChangeFeedSchemaState(db),\n );\n\n if (options.replaceExisting === false) {\n if (await postgresChangeFeedAppendFunctionExists(db)) return;\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n return;\n }\n\n await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);\n}\n\nexport async function ensureChangeFeedTable(\n db: DatabaseInterface,\n): Promise<void> {\n if (ensuredHandles.has(db)) return;\n if (getEngine(db) === 'postgres') {\n const state = await getPostgresChangeFeedSchemaState(db);\n assertPostgresChangeFeedTimestampCurrent(state);\n if (\n state.tableExists &&\n state.functionExists &&\n state.createdAtType === 'timestamp with time zone'\n ) {\n ensuredHandles.add(db);\n return;\n }\n await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);\n } else {\n const statements = CREATE_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0);\n for (const statement of statements) {\n await db.query(statement);\n }\n }\n ensuredHandles.add(db);\n}\n\n// ============================================================================\n// Append (writer primitive + manual bump escape hatch)\n// ============================================================================\n\n/**\n * Append one change entry with a database-allocated, strictly monotonic\n * sequence.\n *\n * The sequence is allocated inside the INSERT itself\n * (`COALESCE(MAX(seq), 0) + 1`) and retried on primary-key conflict, which\n * keeps committed sequences contiguous and makes commit order equal\n * sequence order — the property the cursor guarantee rests on (see the\n * module docs). Throws after {@link MAX_APPEND_ATTEMPTS} consecutive\n * conflicts or on any non-conflict database error; the framework's\n * interceptor catches and logs instead of failing the user's write.\n *\n * **PostgreSQL transaction safety (#2026).** The INSERT runs inside the\n * framework-owned `_smrt_append_change` PL/pgSQL function. Its exception\n * handler is a PostgreSQL subtransaction: a failed attempt is rolled back\n * before the function returns `{ error_code, error_message }`. This method\n * then throws in JavaScript, where the existing retry/swallow policy applies\n * without aborting a caller-managed transaction. Keeping isolation inside one\n * database statement also prevents concurrent work on the same transaction\n * handle from interleaving inside a manual SAVEPOINT scope.\n */\nexport async function appendChange(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<number> {\n const table = input.table?.trim();\n if (!table) {\n throw new Error('appendChange requires a non-empty table name');\n }\n const operation = input.operation ?? 'update';\n if (!VALID_OPERATIONS.has(operation)) {\n throw new Error(\n `appendChange operation must be one of create/update/delete, got '${String(\n input.operation,\n )}'`,\n );\n }\n\n const engine = getEngine(db);\n const p = placeholders(db);\n // The INSERT yields the ACTUAL sequence it allocated in the SAME statement\n // (directly via RETURNING on portable engines, through the function on\n // PostgreSQL). A separate follow-up `SELECT MAX(seq)` is racy under concurrent\n // appends (a peer can commit a higher seq in between), which would hand two\n // distinct changes the same SSE `id` and let a client's `Last-Event-ID`\n // overshoot a change it never received. The allocator stays `MAX+1` under the\n // unique-PK retry, so committed sequences remain contiguous (the cursor\n // guarantee — see module docs).\n const sql =\n engine === 'postgres'\n ? `SELECT allocated_seq, error_code, error_message FROM ` +\n `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(` +\n `${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`\n : `INSERT INTO ${CHANGE_FEED_TABLE} ` +\n '(seq, table_name, row_id, operation, tenant_id, created_at) ' +\n `SELECT COALESCE(MAX(seq), 0) + 1, ${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)} ` +\n `FROM ${CHANGE_FEED_TABLE} RETURNING seq`;\n const params = [\n table,\n input.rowId ?? null,\n operation,\n input.tenantId ?? null,\n new Date().toISOString(),\n ];\n\n for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) {\n try {\n // The append is a root-connection write; on embedded engines it goes\n // through the per-database write queue so it can never overlap a\n // null-aware upsert's second-connection transaction (#2360 — the pair\n // livelocked into SQLITE_BUSY under concurrent NULL-tenant creates).\n const rows = getQueryRows(\n await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () =>\n db.query(sql, ...params),\n ),\n );\n const row = rows[0];\n if (!row) {\n throw new Error('Change feed append returned no result row');\n }\n if (engine === 'postgres' && row.error_code != null) {\n const error = new Error(\n String(row.error_message || 'PostgreSQL change-feed append failed'),\n ) as Error & { code: string };\n error.code = String(row.error_code);\n throw error;\n }\n return toSeqNumber(engine === 'postgres' ? row.allocated_seq : row.seq);\n } catch (error) {\n if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {\n throw error;\n }\n // Sequence head contention: another append won the value. Re-running\n // recomputes MAX(seq) against the now-committed head.\n }\n }\n\n // Unreachable: the loop returns a seq or throws on the final attempt. Present\n // so the function satisfies its `Promise<number>` contract structurally.\n throw new Error('appendChange exhausted retries without allocating a seq');\n}\n\n/**\n * Manual bump escape hatch for out-of-band writers.\n *\n * Framework mutation paths feed the log automatically, but raw SQL issued\n * outside `save()`/`delete()` is invisible to it (documented gap, shared\n * with the #1499 collection cache). Call this after such a write so feed\n * consumers observe the change. Omitting `rowId` records a table-level\n * change (`rowId: null`), which consumers should treat as \"anything in this\n * table may have changed\".\n *\n * @example\n * ```typescript\n * await db.query(`UPDATE products SET price = price * 1.1`);\n * await bumpChangeFeed(db, { table: 'products' });\n * ```\n */\nexport async function bumpChangeFeed(\n db: DatabaseInterface,\n input: AppendChangeInput,\n): Promise<void> {\n await ensureChangeFeedTable(db);\n await appendChange(db, input);\n}\n\n// ============================================================================\n// Read interface\n// ============================================================================\n\n/**\n * Read committed changes after a cursor.\n *\n * Returns every committed change with `since < seq <= cursor` that matches\n * the filters, ordered by ascending `seq`. The returned cursor is safe to\n * persist and poll with: committed sequences are contiguous (see module\n * docs), so nothing can commit at or below the observed horizon afterwards —\n * reads miss no committed change under concurrent writers and never return\n * the same change twice. When `since` is already at the horizon, returns an\n * empty page with `cursor: since`.\n *\n * ## Resync detection (pruned / foreign cursors)\n *\n * A cursor that cannot be served incrementally is flagged with\n * `resyncRequired: true` (empty `changes`, `cursor` echoed unchanged,\n * `resyncCursor` set to the current horizon) so pollers never go silently,\n * permanently stale:\n *\n * - **Pruned gap**: retained sequences always form a contiguous run\n * `[floor..horizon]` and {@link pruneChangeFeed} deletes oldest-first\n * while always retaining the newest entry, so `since < floor - 1` proves\n * changes between the cursor and the retained window were pruned away.\n * - **Foreign/reset cursor**: `since > horizon` (ahead of anything this\n * database ever allocated), including any `since > 0` against a feed\n * with no entries.\n *\n * Detection runs on the **unfiltered** log — `tables`/`tenantId` filters\n * legitimately hide rows and never trigger (or mask) the signal. A caught-up\n * consumer (`since === horizon`) is never asked to resync, even when\n * retention has pruned everything older.\n *\n * Filters (`tables`, `tenantId`) affect which rows are *returned*, never how\n * the cursor advances — an exhausted filtered page still advances to the\n * horizon so pollers do not rescan filtered-out rows.\n */\nexport async function getChangesSince(\n db: DatabaseInterface,\n options: GetChangesOptions,\n): Promise<ChangeFeedPage> {\n const { since } = options;\n if (!Number.isFinite(since) || since < 0) {\n throw new Error(\n `getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`,\n );\n }\n const limit = Math.min(\n Math.max(Math.floor(options.limit ?? DEFAULT_CHANGES_LIMIT), 1),\n MAX_CHANGES_LIMIT,\n );\n\n const p = placeholders(db);\n\n // The committed horizon: every seq <= horizon is committed and immutable\n // (append-only + contiguous allocation), so the page below is stable even\n // though it runs as a separate statement. The floor bounds the retained\n // window for pruned-cursor detection; both are computed UNFILTERED so\n // table/tenant filters can neither trigger nor mask a resync signal.\n const boundsRows = getQueryRows(\n await db.query(\n `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`,\n ),\n );\n const floor = toSeqNumber(boundsRows[0]?.floor);\n const horizon = toSeqNumber(boundsRows[0]?.horizon);\n\n if (horizon === 0) {\n // No entries at all. A zero cursor is simply \"no changes ever\"; any\n // other cursor came from a different database (or a reset feed) and\n // cannot be served incrementally.\n return since === 0\n ? { changes: [], cursor: 0 }\n : { changes: [], cursor: since, resyncRequired: true, resyncCursor: 0 };\n }\n\n if (since > horizon) {\n // Foreign or reset cursor — ahead of anything this database allocated.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (since < floor - 1) {\n // Pruned gap — the changes with seq in (since, floor) are gone for good.\n return {\n changes: [],\n cursor: since,\n resyncRequired: true,\n resyncCursor: horizon,\n };\n }\n\n if (horizon === since) {\n return { changes: [], cursor: since };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n let index = 0;\n const next = () => p(++index);\n\n conditions.push(`seq > ${next()}`);\n params.push(since);\n conditions.push(`seq <= ${next()}`);\n params.push(horizon);\n\n const tables = options.tables?.filter((table) => table.trim().length > 0);\n if (tables && tables.length > 0) {\n conditions.push(`table_name IN (${tables.map(() => next()).join(', ')})`);\n params.push(...tables);\n }\n\n if (options.tenantId === null) {\n conditions.push('tenant_id IS NULL');\n } else if (typeof options.tenantId === 'string') {\n conditions.push(`(tenant_id = ${next()} OR tenant_id IS NULL)`);\n params.push(options.tenantId);\n }\n\n const sql =\n 'SELECT seq, table_name, row_id, operation, tenant_id, created_at ' +\n `FROM ${CHANGE_FEED_TABLE} WHERE ${conditions.join(' AND ')} ` +\n `ORDER BY seq ASC LIMIT ${next()}`;\n params.push(limit);\n\n const rows = getQueryRows(await db.query(sql, ...params));\n const changes = rows.map(rowToEntry);\n\n // Page limited → resume after the last returned row. Page exhaustive →\n // everything up to the horizon (matching or filtered out) has been\n // observed, so advance all the way.\n const cursor =\n changes.length === limit ? changes[changes.length - 1].seq : horizon;\n\n return { changes, cursor };\n}\n\n/**\n * {@link getChangesSince} scoped by the active tenant context.\n *\n * Resolves the tenant through the same dependency-inversion hook the\n * DispatchBus uses ({@link resolveDispatchTenantScope}), so it works without\n * core depending on `@happyvertical/smrt-tenancy`:\n *\n * - Tenancy disabled (no resolver registered) → no tenant filter.\n * - Tenancy enabled with an active tenant `T` → `T`'s rows plus global rows.\n * - Tenancy enabled with **no** active tenant → global rows only\n * (**fail-closed**: a missing context never widens visibility to all\n * tenants).\n *\n * This is the read the generated `_changes` routes call after establishing\n * tenant context from the authenticated principal.\n */\nexport async function getTenantScopedChangesSince(\n db: DatabaseInterface,\n options: Omit<GetChangesOptions, 'tenantId'>,\n): Promise<ChangeFeedPage> {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) {\n return getChangesSince(db, options);\n }\n return getChangesSince(db, { ...options, tenantId: scope.tenantId });\n}\n\n/**\n * The per-table change version — the ETag source for zero-query conditional\n * GETs (#1765).\n *\n * Returns `MAX(seq)` over the feed rows for `table`: a monotonic number that\n * advances on every framework write to that table (create/update/delete, and\n * writes through the sync-apply endpoint, which all `save()`/`delete()`).\n * Because sequences are the change feed's globally-monotonic cursor dimension\n * (allocated `MAX+1` at commit time, never a native identity — see the module\n * docs), the value is **replica-stable**: two processes reading the same\n * committed database compute the same version, with no per-process divergence.\n * That is what lets a generated read route derive an ETag that short-circuits a\n * matching `If-None-Match` into a `304` before the collection query runs — an\n * unchanged table costs one indexed `MAX(seq)` lookup (backed by\n * `idx_smrt_changes_table_seq`) to revalidate, not a table scan.\n *\n * ## Why the fallback to the global horizon (and not 0)\n *\n * A table with no *retained* feed entry falls back to the global horizon\n * (`MAX(seq)` across all tables), returning 0 only when the whole feed is\n * empty. Retention prunes oldest-first and always keeps the newest entry, so a\n * quiet table can lose all of its own entries while busier tables advance. If\n * such a table reported 0, a client that cached it while it was empty (version\n * 0) could, after a change→prune→change→prune cycle returned the lookup to 0,\n * be wrongly answered `304` against data that has since changed — a false-304.\n *\n * The horizon fallback closes that hole: any write to the table appends a new\n * sequence strictly greater than every previously-observed value (its own or\n * the horizon), so the version — and therefore the ETag — strictly exceeds any\n * value a client already holds, forcing a fresh `200`. The only cost is that a\n * table with no retained entries of its own revalidates whenever the global\n * horizon moves; a table with a retained entry uses its own stable `MAX(seq)`\n * and is unaffected by writes to sibling tables. A persistent per-table\n * high-water mark that survives pruning would remove even that cost; it is a\n * deliberate follow-up, out of scope for this slice.\n *\n * Idempotently ensures the feed table exists first, so it is safe to call from\n * a read route on a raw handle that has never been written to.\n */\nexport async function getTableVersion(\n db: DatabaseInterface,\n table: string,\n): Promise<number> {\n const name = table?.trim();\n if (!name) {\n throw new Error('getTableVersion requires a non-empty table name');\n }\n await ensureChangeFeedTable(db);\n\n const p = placeholders(db);\n const tableRows = getQueryRows(\n await db.query(\n `SELECT MAX(seq) AS version FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}`,\n name,\n ),\n );\n const tableVersion = tableRows[0]?.version;\n if (tableVersion != null) {\n return toSeqNumber(tableVersion);\n }\n\n // No retained entry for this table — fall back to the global horizon so an\n // all-pruned (or never-written) table never reports a resettable low value\n // that could false-304 a stale client. 0 only when the feed is empty.\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n return toSeqNumber(horizonRows[0]?.horizon);\n}\n\nfunction toSeqNumber(value: unknown): number {\n // PostgreSQL surfaces BIGINT aggregates as strings and DuckDB can return\n // BigInt. Change-feed cursors must never advance on a rounded value.\n return toSafeInteger(value ?? 0, 'Change-feed sequence');\n}\n\nfunction rowToEntry(row: Record<string, unknown>): ChangeFeedEntry {\n return {\n seq: toSeqNumber(row.seq),\n table: String(row.table_name ?? ''),\n rowId: row.row_id == null ? null : String(row.row_id),\n operation: String(row.operation ?? 'update') as ChangeOperation,\n tenantId: row.tenant_id == null ? null : String(row.tenant_id),\n timestamp: normalizeTimestamp(row.created_at),\n };\n}\n\nfunction normalizeTimestamp(value: unknown): string {\n if (value instanceof Date) return value.toISOString();\n return String(value ?? '');\n}\n\n// ============================================================================\n// Retention / compaction\n// ============================================================================\n\n/**\n * Prune the change feed to bound its growth.\n *\n * Applies whichever bounds are provided (at least one is required):\n * - `maxRows`: keep only the newest N entries by sequence.\n * - `maxAgeMs`: drop entries older than the cutoff.\n *\n * Pruning deletes oldest-first, never renumbers surviving entries, and\n * **always retains the newest entry** (a non-empty feed is never emptied,\n * whatever the bounds say). That invariant anchors pruned-cursor detection:\n * retained sequences stay a contiguous run `[floor..horizon]`, so\n * {@link getChangesSince} can prove a cursor predates the retained window\n * (`resyncRequired`) — and a fully caught-up consumer keeps polling\n * normally even after everything older was pruned.\n *\n * Cursors within the retained window keep working. Schedule pruning (e.g.\n * via `@happyvertical/smrt-jobs`) with a retention window comfortably\n * larger than the slowest consumer's polling interval; consumers whose\n * cursor falls out of it are told to full-resync via `resyncRequired`.\n *\n * @returns The number of entries pruned — or, with `dryRun`, the number the\n * same bounds would prune (approximate under concurrent prunes).\n */\nexport async function pruneChangeFeed(\n db: DatabaseInterface,\n retention: ChangeFeedRetention,\n): Promise<{ pruned: number }> {\n const { maxAgeMs, maxRows, dryRun = false } = retention;\n if (maxAgeMs == null && maxRows == null) {\n throw new Error('pruneChangeFeed requires maxAgeMs and/or maxRows');\n }\n if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {\n throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);\n }\n if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) {\n throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);\n }\n\n const p = placeholders(db);\n\n // Snapshot the horizon once: both bounds prune strictly below it so the\n // newest entry always survives (see resync-detection contract above).\n const horizonRows = getQueryRows(\n await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`),\n );\n const horizon = toSeqNumber(horizonRows[0]?.horizon);\n if (horizon === 0) {\n return { pruned: 0 };\n }\n\n let pruned = 0;\n let prunedThrough = 0;\n\n if (maxRows != null) {\n const pruneThrough = Math.min(horizon - Math.floor(maxRows), horizon - 1);\n if (pruneThrough > 0) {\n prunedThrough = pruneThrough;\n pruned += await deleteCounted(\n db,\n `seq <= ${p(1)}`,\n [pruneThrough],\n dryRun,\n );\n }\n }\n\n if (maxAgeMs != null) {\n const cutoff = new Date(Date.now() - maxAgeMs).toISOString();\n // `seq > prunedThrough` excludes what the row bound already accounted for.\n // Redundant when the rows were really deleted, load-bearing under\n // `dryRun`, where nothing was — without it overlapping entries would be\n // counted by both bounds.\n pruned += await deleteCounted(\n db,\n `created_at < ${p(1)} AND seq < ${p(2)} AND seq > ${p(3)}`,\n [cutoff, horizon, prunedThrough],\n dryRun,\n );\n }\n\n return { pruned };\n}\n\nasync function deleteCounted(\n db: DatabaseInterface,\n condition: string,\n params: unknown[],\n dryRun = false,\n): Promise<number> {\n const countRows = getQueryRows(\n await db.query(\n `SELECT COUNT(*) AS total FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n ),\n );\n const total = toSeqNumber(countRows[0]?.total);\n if (total > 0 && !dryRun) {\n await db.query(\n `DELETE FROM ${CHANGE_FEED_TABLE} WHERE ${condition}`,\n ...params,\n );\n }\n return total;\n}\n\n// ============================================================================\n// Framework writer (GlobalInterceptors registration)\n// ============================================================================\n\nexport const CHANGE_FEED_WAS_PERSISTED_KEY = '_smrtChangeFeedWasPersisted';\n\n/** Databases we already warned about after a failed feed append. */\nconst warnedAppendFailures = new Set<string>();\n\n/** Databases we already warned about after a failed signal publish (#1763). */\nconst warnedSignalPublishFailures = new Set<string>();\n\n/**\n * Register the change-feed writer with {@link GlobalInterceptors}.\n *\n * Called automatically during framework initialization (every\n * `SmrtClass.initialize()` passes through it), so applications never need\n * to call it directly; it is exported for tests and for re-registering\n * after `GlobalInterceptors.clear()`. Idempotent — a second call while the\n * writer is registered is a no-op.\n *\n * The writer observes the same hooks the reports scheduler and tenancy\n * interceptors use:\n * - `beforeSave` stashes whether the instance was already persisted (this\n * is what distinguishes `create` from `update` in the feed).\n * - `afterSave`/`afterDelete` append exactly one change entry per framework\n * save/delete. Framework-owned tables listed in\n * {@link CHANGE_FEED_EXCLUDED_TABLES} are skipped — the feed observes\n * application data, not framework bookkeeping (and never itself).\n *\n * Failure policy: appends run after the user's write succeeded and must not\n * un-succeed it — failures are logged (deduped per database) and swallowed.\n */\nexport function registerChangeFeedWriter(): void {\n if (\n GlobalInterceptors.getAll().some(\n (interceptor) => interceptor.name === CHANGE_FEED_INTERCEPTOR_NAME,\n )\n ) {\n return;\n }\n\n GlobalInterceptors.register({\n name: CHANGE_FEED_INTERCEPTOR_NAME,\n // Below tenancy (100) so tenantId auto-population precedes the stash;\n // above the reports refresh interceptor (-10) so a triggered refresh\n // can already observe the appended change entry.\n priority: 0,\n\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n try {\n context.metadata = {\n ...context.metadata,\n [CHANGE_FEED_WAS_PERSISTED_KEY]: instance.isPersisted === true,\n };\n } catch {\n // Never let feed bookkeeping block a save.\n }\n },\n\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n const wasPersisted =\n context.metadata?.[CHANGE_FEED_WAS_PERSISTED_KEY] === true;\n await appendForInstance(instance, wasPersisted ? 'update' : 'create');\n },\n\n async afterDelete(instance: SmrtObject): Promise<void> {\n await appendForInstance(instance, 'delete');\n },\n });\n}\n\n/** Unregister the change-feed writer (test helper). */\nexport function unregisterChangeFeedWriter(): boolean {\n return GlobalInterceptors.unregister(CHANGE_FEED_INTERCEPTOR_NAME);\n}\n\nasync function appendForInstance(\n instance: SmrtObject,\n operation: ChangeOperation,\n): Promise<void> {\n let db: DatabaseInterface;\n let table: string;\n try {\n table = instance.tableName;\n // Framework-owned tables are bookkeeping, not client-syncable data —\n // recording them would let the feed observe (and re-observe) itself. The\n // test is an allowlist, not the `_smrt_` prefix: ~25 domain tables carry\n // that prefix and must be observed (issue #2376).\n if (!isChangeFeedObservableTable(table)) return;\n db = instance.db;\n } catch {\n // Not a fully initialized SmrtObject (e.g. plain-object doubles in\n // tests) — nothing to record.\n return;\n }\n\n try {\n const id = (instance as { id?: unknown }).id;\n const tenantId = (instance as unknown as Record<string, unknown>).tenantId;\n const rowId = typeof id === 'string' && id ? id : null;\n const rowTenantId =\n typeof tenantId === 'string' && tenantId ? tenantId : null;\n const seq = await appendChange(db, {\n table,\n rowId,\n operation,\n tenantId: rowTenantId,\n });\n\n // Publish a coarse live signal for the SSE `_events` route (#1763). This\n // runs only after the durable feed append SUCCEEDED (same try block, so a\n // failed append never emits a signal — \"no signal without a durable feed\n // row\"). Its own try/catch (distinct dedup key) keeps a signal-publish\n // problem from failing the user's write or masking the append's own\n // failure semantics above.\n try {\n publishChangeSignal(db, {\n table,\n operation,\n rowId,\n tenantId: rowTenantId,\n seq,\n });\n } catch (error) {\n warnSignalPublishFailureOnce(db, table, error);\n }\n } catch (error) {\n warnAppendFailureOnce(db, table, error);\n }\n}\n\n/** Record a framework-owned mutation that intentionally bypasses save hooks. */\nexport async function recordInstanceChange(\n instance: SmrtObject,\n operation: ChangeOperation = 'update',\n): Promise<void> {\n await appendForInstance(instance, operation);\n}\n\nfunction warnAppendFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedAppendFailures.has(dbKey)) return;\n warnedAppendFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to append a change entry for '${table}'. The ` +\n 'write itself succeeded; the feed is missing this change (further ' +\n 'failures for this database are suppressed). Consumers recover on ' +\n 'full resync.',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\nfunction warnSignalPublishFailureOnce(\n db: DatabaseInterface,\n table: string,\n error: unknown,\n): void {\n try {\n const dbKey = resolveDbCacheKey(db);\n if (warnedSignalPublishFailures.has(dbKey)) return;\n warnedSignalPublishFailures.add(dbKey);\n logger.warn(\n `Change feed: failed to publish a live change signal for '${table}'. ` +\n 'The write and its durable feed row are unaffected; live SSE ' +\n 'subscribers miss this signal but recover via cursor catch-up ' +\n '(further failures for this database are suppressed).',\n { error: error instanceof Error ? error.message : String(error) },\n );\n } catch {\n // Logging must never propagate into the write path.\n }\n}\n\n/**\n * Reset the append-failure and signal-publish warning dedups (test helper).\n */\nexport function resetChangeFeedWarnings(): void {\n warnedAppendFailures.clear();\n warnedSignalPublishFailures.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqHA,IAAM,SAAS,aAAa,EAAE,OAAO,OAAO,CAAC;;AAG7C,IAAa,oBAAoB;;;;;;;;;;;;;;;;AAiBjC,IAAa,8CAAmD,IAAI,IAAI;CACtE,GAAG;CACH,GAAG;CACH,GAAG;AACL,CAAC;;;;;;;AAQD,SAAgB,4BAA4B,WAA4B;CACtE,OAAO,QAAQ,SAAS,KAAK,CAAC,4BAA4B,IAAI,SAAS;AACzE;;AAGA,IAAa,+BAA+B;;AAgI5C,IAAa,wBAAwB;;AAGrC,IAAa,oBAAoB;;;;;;AAOjC,IAAM,sBAAsB;AAE5B,IAAM,mCAAwC,IAAI,IAAI;CACpD;CACA;CACA;AACF,CAAC;AAWD,SAAS,UACP,IACA,UACiC;CACjC,MAAM,aAAa;CACnB,OAAO,aACL,GAAG,OAAO,WAAW,QAAQ,OAAO,IACpC,YAAY,WAAW,QAAQ,WAAW,QAAQ,IACpD;AACF;;;;AAKA,SAAS,aAAa,IAAkD;CAEtE,OADe,UAAU,EAClB,MAAW,cAAc,UAAU,IAAI,gBAAgB;AAChE;AAEA,SAAS,aAAa,QAA4C;CAChE,IAAI,MAAM,QAAQ,MAAM,GACtB,OAAO;CAET,IAAI,UAAU,OAAO,WAAW,YAAY,UAAU,QAAQ;EAC5D,MAAM,OAAQ,OAA8B;EAC5C,IAAI,MAAM,QAAQ,IAAI,GACpB,OAAO;CAEX;CACA,OAAO,CAAC;AACV;AAEA,SAAS,kBAAkB,OAAyB;CAClD,MAAM,UAAoB,CAAC;CAC3B,MAAM,UAAqB,CAAC,KAAK;CACjC,MAAM,uBAAO,IAAI,IAAY;CAE7B,OAAO,QAAQ,SAAS,KAAK,KAAK,OAAO,GAAG;EAC1C,MAAM,YAAY,QAAQ,MAAM;EAChC,IAAI,OAAO,cAAc,UAAU;GACjC,QAAQ,KAAK,SAAS;GACtB;EACF;EACA,IAAI,CAAC,aAAa,OAAO,cAAc,YAAY,KAAK,IAAI,SAAS,GACnE;EAEF,KAAK,IAAI,SAAS;EAElB,MAAM,SAAS;EAMf,IAAI,OAAO,OAAO,YAAY,UAAU,QAAQ,KAAK,OAAO,OAAO;EACnE,IAAI,OAAO,OAAO,SAAS,UAAU,QAAQ,KAAK,OAAO,IAAI;EAC7D,IAAI,OAAO,UAAU,KAAA,GAAW,QAAQ,KAAK,OAAO,KAAK;EAEzD,IAAI,OAAO,WAAW,OAAO,OAAO,YAAY,UAAU;GACxD,MAAM,gBAAiB,OAAO,QAC3B;GACH,IAAI,kBAAkB,KAAA,GAAW,QAAQ,KAAK,aAAa;EAC7D;CACF;CAEA,MAAM,UAAU,QAAQ,KAAK,IAAI;CACjC,OACE,YAAY,KAAK,OAAO,KACxB,qBAAqB,KAAK,OAAO,KACjC,iBAAiB,KAAK,OAAO,KAC7B,0BAA0B,KAAK,OAAO,KACtC,oBAAoB,KAAK,OAAO;AAEpC;;;;;;;;AASA,IAAM,iCAAiB,IAAI,QAAgB;AAE3C,eAAe,uCACb,IACkB;CAClB,MAAM,OAAO,aACX,MAAM,GAAG,MACP,2BAA2B,8CAA8C,oBAC3E,CACF;CACA,OAAO,QAAQ,KAAK,EAAE,EAAE,aAAa;AACvC;AAEA,eAAe,iCACb,IAKC;CACD,MAAM,OAAO,aACX,MAAM,GAAG,MACP;wBACkB,kBAAkB;4BACd,8CAA8C;;;;;iCAKzC,kBAAkB;;8BAG/C,CACF;CACA,OAAO;EACL,aAAa,QAAQ,KAAK,EAAE,EAAE,UAAU;EACxC,gBAAgB,QAAQ,KAAK,EAAE,EAAE,aAAa;EAC9C,eAAe,KAAK,EAAE,EAAE,kBACpB,OAAO,KAAK,EAAE,CAAC,eAAe,IAC9B;CACN;AACF;AAEA,SAAS,yCACP,OACM;CACN,IAAI,MAAM,kBAAkB,+BAC1B,MAAM,IAAI,MACR,uIACF;AAEJ;;;;;;;;;;;;;AAcA,eAAsB,uCACpB,IACA,UAGI,CAAC,GACU;CACf,IAAI,UAAU,IAAI,QAAQ,QAAQ,MAAM,YAAY;CAEpD,yCACE,MAAM,iCAAiC,EAAE,CAC3C;CAEA,IAAI,QAAQ,oBAAoB,OAAO;EACrC,IAAI,MAAM,uCAAuC,EAAE,GAAG;EACtD,MAAM,GAAG,MAAM,2CAA2C;EAC1D;CACF;CAEA,MAAM,GAAG,MAAM,4CAA4C;AAC7D;AAEA,eAAsB,sBACpB,IACe;CACf,IAAI,eAAe,IAAI,EAAE,GAAG;CAC5B,IAAI,UAAU,EAAE,MAAM,YAAY;EAChC,MAAM,QAAQ,MAAM,iCAAiC,EAAE;EACvD,yCAAyC,KAAK;EAC9C,IACE,MAAM,eACN,MAAM,kBACN,MAAM,kBAAkB,4BACxB;GACA,eAAe,IAAI,EAAE;GACrB;EACF;EACA,MAAM,GAAG,MAAM,kCAAkC;CACnD,OAAO;EACL,MAAM,aAAa,0BAA0B,MAAM,GAAG,CAAC,CACpD,KAAK,cAAc,UAAU,KAAK,CAAC,CAAC,CACpC,QAAQ,cAAc,UAAU,SAAS,CAAC;EAC7C,KAAK,MAAM,aAAa,YACtB,MAAM,GAAG,MAAM,SAAS;CAE5B;CACA,eAAe,IAAI,EAAE;AACvB;;;;;;;;;;;;;;;;;;;;;;AA2BA,eAAsB,aACpB,IACA,OACiB;CACjB,MAAM,QAAQ,MAAM,OAAO,KAAK;CAChC,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,8CAA8C;CAEhE,MAAM,YAAY,MAAM,aAAa;CACrC,IAAI,CAAC,iBAAiB,IAAI,SAAS,GACjC,MAAM,IAAI,MACR,oEAAoE,OAClE,MAAM,SACR,EAAE,EACJ;CAGF,MAAM,SAAS,UAAU,EAAE;CAC3B,MAAM,IAAI,aAAa,EAAE;CASzB,MAAM,MACJ,WAAW,aACP,wDACG,0CAA0C,GAC1C,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,KAC5C,eAAe,kBAAkB,iGAEI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,QACtE,kBAAkB;CAChC,MAAM,SAAS;EACb;EACA,MAAM,SAAS;EACf;EACA,MAAM,YAAY;mBAClB,IAAI,KAAK,EAAA,CAAE,YAAY;CACzB;CAEA,KAAK,IAAI,UAAU,GAAG,WAAW,qBAAqB,WACpD,IAAI;EAUF,MAAM,MALO,aACX,MAAM,uBAAuB,IAAI,mBAAmB,EAAE,SACpD,GAAG,MAAM,KAAK,GAAG,MAAM,CACzB,CAEU,CAAA,CAAK;EACjB,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,2CAA2C;EAE7D,IAAI,WAAW,cAAc,IAAI,cAAc,MAAM;GACnD,MAAM,QAAQ,IAAI,MAChB,OAAO,IAAI,iBAAiB,sCAAsC,CACpE;GACA,MAAM,OAAO,OAAO,IAAI,UAAU;GAClC,MAAM;EACR;EACA,OAAO,YAAY,WAAW,aAAa,IAAI,gBAAgB,IAAI,GAAG;CACxE,SAAS,OAAO;EACd,IAAI,CAAC,kBAAkB,KAAK,KAAK,YAAY,qBAC3C,MAAM;CAIV;CAKF,MAAM,IAAI,MAAM,yDAAyD;AAC3E;;;;;;;;;;;;;;;;;AAkBA,eAAsB,eACpB,IACA,OACe;CACf,MAAM,sBAAsB,EAAE;CAC9B,MAAM,aAAa,IAAI,KAAK;AAC9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,eAAsB,gBACpB,IACA,SACyB;CACzB,MAAM,EAAE,UAAU;CAClB,IAAI,CAAC,OAAO,SAAS,KAAK,KAAK,QAAQ,GACrC,MAAM,IAAI,MACR,gEAAgE,OAAO,KAAK,EAAE,EAChF;CAEF,MAAM,QAAQ,KAAK,IACjB,KAAK,IAAI,KAAK,MAAM,QAAQ,SAAA,GAA8B,GAAG,CAAC,GAC9D,iBACF;CAEA,MAAM,IAAI,aAAa,EAAE;CAOzB,MAAM,aAAa,aACjB,MAAM,GAAG,MACP,sDAAsD,mBACxD,CACF;CACA,MAAM,QAAQ,YAAY,WAAW,EAAE,EAAE,KAAK;CAC9C,MAAM,UAAU,YAAY,WAAW,EAAE,EAAE,OAAO;CAElD,IAAI,YAAY,GAId,OAAO,UAAU,IACb;EAAE,SAAS,CAAC;EAAG,QAAQ;CAAE,IACzB;EAAE,SAAS,CAAC;EAAG,QAAQ;EAAO,gBAAgB;EAAM,cAAc;CAAE;CAG1E,IAAI,QAAQ,SAEV,OAAO;EACL,SAAS,CAAC;EACV,QAAQ;EACR,gBAAgB;EAChB,cAAc;CAChB;CAGF,IAAI,QAAQ,QAAQ,GAElB,OAAO;EACL,SAAS,CAAC;EACV,QAAQ;EACR,gBAAgB;EAChB,cAAc;CAChB;CAGF,IAAI,YAAY,OACd,OAAO;EAAE,SAAS,CAAC;EAAG,QAAQ;CAAM;CAGtC,MAAM,aAAuB,CAAC;CAC9B,MAAM,SAAoB,CAAC;CAC3B,IAAI,QAAQ;CACZ,MAAM,aAAa,EAAE,EAAE,KAAK;CAE5B,WAAW,KAAK,SAAS,KAAK,GAAG;CACjC,OAAO,KAAK,KAAK;CACjB,WAAW,KAAK,UAAU,KAAK,GAAG;CAClC,OAAO,KAAK,OAAO;CAEnB,MAAM,SAAS,QAAQ,QAAQ,QAAQ,UAAU,MAAM,KAAK,CAAC,CAAC,SAAS,CAAC;CACxE,IAAI,UAAU,OAAO,SAAS,GAAG;EAC/B,WAAW,KAAK,kBAAkB,OAAO,UAAU,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,EAAE;EACxE,OAAO,KAAK,GAAG,MAAM;CACvB;CAEA,IAAI,QAAQ,aAAa,MACvB,WAAW,KAAK,mBAAmB;MAC9B,IAAI,OAAO,QAAQ,aAAa,UAAU;EAC/C,WAAW,KAAK,gBAAgB,KAAK,EAAE,uBAAuB;EAC9D,OAAO,KAAK,QAAQ,QAAQ;CAC9B;CAEA,MAAM,MACJ,yEACQ,kBAAkB,SAAS,WAAW,KAAK,OAAO,EAAE,0BAClC,KAAK;CACjC,OAAO,KAAK,KAAK;CAGjB,MAAM,UADO,aAAa,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM,CACvC,CAAA,CAAK,IAAI,UAAU;CAQnC,OAAO;EAAE;EAAS,QAFhB,QAAQ,WAAW,QAAQ,QAAQ,QAAQ,SAAS,EAAE,CAAC,MAAM;CAEtC;AAC3B;;;;;;;;;;;;;;;;;AAkBA,eAAsB,4BACpB,IACA,SACyB;CACzB,MAAM,QAAQ,2BAA2B;CACzC,IAAI,CAAC,MAAM,UACT,OAAO,gBAAgB,IAAI,OAAO;CAEpC,OAAO,gBAAgB,IAAI;EAAE,GAAG;EAAS,UAAU,MAAM;CAAS,CAAC;AACrE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,eAAsB,gBACpB,IACA,OACiB;CACjB,MAAM,OAAO,OAAO,KAAK;CACzB,IAAI,CAAC,MACH,MAAM,IAAI,MAAM,iDAAiD;CAEnE,MAAM,sBAAsB,EAAE;CAE9B,MAAM,IAAI,aAAa,EAAE;CAOzB,MAAM,eANY,aAChB,MAAM,GAAG,MACP,mCAAmC,kBAAkB,sBAAsB,EAAE,CAAC,KAC9E,IACF,CAEmB,CAAA,CAAU,EAAE,EAAE;CACnC,IAAI,gBAAgB,MAClB,OAAO,YAAY,YAAY;CASjC,OAAO,YAHa,aAClB,MAAM,GAAG,MAAM,mCAAmC,mBAAmB,CAEpD,CAAA,CAAY,EAAE,EAAE,OAAO;AAC5C;AAEA,SAAS,YAAY,OAAwB;CAG3C,OAAO,cAAc,SAAS,GAAG,sBAAsB;AACzD;AAEA,SAAS,WAAW,KAA+C;CACjE,OAAO;EACL,KAAK,YAAY,IAAI,GAAG;EACxB,OAAO,OAAO,IAAI,cAAc,EAAE;EAClC,OAAO,IAAI,UAAU,OAAO,OAAO,OAAO,IAAI,MAAM;EACpD,WAAW,OAAO,IAAI,aAAa,QAAQ;EAC3C,UAAU,IAAI,aAAa,OAAO,OAAO,OAAO,IAAI,SAAS;EAC7D,WAAW,mBAAmB,IAAI,UAAU;CAC9C;AACF;AAEA,SAAS,mBAAmB,OAAwB;CAClD,IAAI,iBAAiB,MAAM,OAAO,MAAM,YAAY;CACpD,OAAO,OAAO,SAAS,EAAE;AAC3B;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,eAAsB,gBACpB,IACA,WAC6B;CAC7B,MAAM,EAAE,UAAU,SAAS,SAAS,UAAU;CAC9C,IAAI,YAAY,QAAQ,WAAW,MACjC,MAAM,IAAI,MAAM,kDAAkD;CAEpE,IAAI,YAAY,SAAS,CAAC,OAAO,SAAS,QAAQ,KAAK,WAAW,IAChE,MAAM,IAAI,MAAM,8CAA8C,UAAU;CAE1E,IAAI,WAAW,SAAS,CAAC,OAAO,SAAS,OAAO,KAAK,UAAU,IAC7D,MAAM,IAAI,MAAM,6CAA6C,SAAS;CAGxE,MAAM,IAAI,aAAa,EAAE;CAOzB,MAAM,UAAU,YAHI,aAClB,MAAM,GAAG,MAAM,mCAAmC,mBAAmB,CAE3C,CAAA,CAAY,EAAE,EAAE,OAAO;CACnD,IAAI,YAAY,GACd,OAAO,EAAE,QAAQ,EAAE;CAGrB,IAAI,SAAS;CACb,IAAI,gBAAgB;CAEpB,IAAI,WAAW,MAAM;EACnB,MAAM,eAAe,KAAK,IAAI,UAAU,KAAK,MAAM,OAAO,GAAG,UAAU,CAAC;EACxE,IAAI,eAAe,GAAG;GACpB,gBAAgB;GAChB,UAAU,MAAM,cACd,IACA,UAAU,EAAE,CAAC,KACb,CAAC,YAAY,GACb,MACF;EACF;CACF;CAEA,IAAI,YAAY,MAAM;EACpB,MAAM,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,CAAC,CAAC,YAAY;EAK3D,UAAU,MAAM,cACd,IACA,gBAAgB,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,KACvD;GAAC;GAAQ;GAAS;EAAa,GAC/B,MACF;CACF;CAEA,OAAO,EAAE,OAAO;AAClB;AAEA,eAAe,cACb,IACA,WACA,QACA,SAAS,OACQ;CAOjB,MAAM,QAAQ,YANI,aAChB,MAAM,GAAG,MACP,iCAAiC,kBAAkB,SAAS,aAC5D,GAAG,MACL,CAEwB,CAAA,CAAU,EAAE,EAAE,KAAK;CAC7C,IAAI,QAAQ,KAAK,CAAC,QAChB,MAAM,GAAG,MACP,eAAe,kBAAkB,SAAS,aAC1C,GAAG,MACL;CAEF,OAAO;AACT;AAMA,IAAa,gCAAgC;;AAG7C,IAAM,uCAAuB,IAAI,IAAY;;AAG7C,IAAM,8CAA8B,IAAI,IAAY;;;;;;;;;;;;;;;;;;;;;;AAuBpD,SAAgB,2BAAiC;CAC/C,IACE,mBAAmB,OAAO,CAAC,CAAC,MACzB,gBAAgB,YAAY,SAAA,kBAC/B,GAEA;CAGF,mBAAmB,SAAS;EAC1B,MAAM;EAIN,UAAU;EAEV,WAAW,UAAsB,SAAmC;GAClE,IAAI;IACF,QAAQ,WAAW;KACjB,GAAG,QAAQ;MACV,gCAAgC,SAAS,gBAAgB;IAC5D;GACF,QAAQ,CAER;EACF;EAEA,MAAM,UACJ,UACA,SACe;GAGf,MAAM,kBAAkB,UADtB,QAAQ,WAAA,mCAA8C,OACP,WAAW,QAAQ;EACtE;EAEA,MAAM,YAAY,UAAqC;GACrD,MAAM,kBAAkB,UAAU,QAAQ;EAC5C;CACF,CAAC;AACH;;AAGA,SAAgB,6BAAsC;CACpD,OAAO,mBAAmB,WAAW,4BAA4B;AACnE;AAEA,eAAe,kBACb,UACA,WACe;CACf,IAAI;CACJ,IAAI;CACJ,IAAI;EACF,QAAQ,SAAS;EAKjB,IAAI,CAAC,4BAA4B,KAAK,GAAG;EACzC,KAAK,SAAS;CAChB,QAAQ;EAGN;CACF;CAEA,IAAI;EACF,MAAM,KAAM,SAA8B;EAC1C,MAAM,WAAY,SAAgD;EAClE,MAAM,QAAQ,OAAO,OAAO,YAAY,KAAK,KAAK;EAClD,MAAM,cACJ,OAAO,aAAa,YAAY,WAAW,WAAW;EACxD,MAAM,MAAM,MAAM,aAAa,IAAI;GACjC;GACA;GACA;GACA,UAAU;EACZ,CAAC;EAQD,IAAI;GACF,oBAAoB,IAAI;IACtB;IACA;IACA;IACA,UAAU;IACV;GACF,CAAC;EACH,SAAS,OAAO;GACd,6BAA6B,IAAI,OAAO,KAAK;EAC/C;CACF,SAAS,OAAO;EACd,sBAAsB,IAAI,OAAO,KAAK;CACxC;AACF;;AAGA,eAAsB,qBACpB,UACA,YAA6B,UACd;CACf,MAAM,kBAAkB,UAAU,SAAS;AAC7C;AAEA,SAAS,sBACP,IACA,OACA,OACM;CACN,IAAI;EACF,MAAM,QAAQ,kBAAkB,EAAE;EAClC,IAAI,qBAAqB,IAAI,KAAK,GAAG;EACrC,qBAAqB,IAAI,KAAK;EAC9B,OAAO,KACL,qDAAqD,MAAM,wJAI3D,EAAE,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,CAClE;CACF,QAAQ,CAER;AACF;AAEA,SAAS,6BACP,IACA,OACA,OACM;CACN,IAAI;EACF,MAAM,QAAQ,kBAAkB,EAAE;EAClC,IAAI,4BAA4B,IAAI,KAAK,GAAG;EAC5C,4BAA4B,IAAI,KAAK;EACrC,OAAO,KACL,4DAA4D,MAAM,mLAIlE,EAAE,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,CAClE;CACF,QAAQ,CAER;AACF;;;;AAKA,SAAgB,0BAAgC;CAC9C,qBAAqB,MAAM;CAC3B,4BAA4B,MAAM;AACpC"}