@happyvertical/smrt-core 0.51.28 → 0.51.32

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 (105) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +47 -0
  3. package/agents/build-knowledge.md +16 -0
  4. package/agents/change-feed.md +95 -4
  5. package/agents/change-signals.md +6 -1
  6. package/agents/run-once.md +268 -0
  7. package/agents/schema-paths.md +19 -5
  8. package/dist/browser.d.ts +1 -0
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +5 -3
  11. package/dist/change-feed-authz.d.ts +141 -0
  12. package/dist/change-feed-authz.d.ts.map +1 -0
  13. package/dist/change-feed-authz.js +260 -0
  14. package/dist/change-feed-authz.js.map +1 -0
  15. package/dist/change-feed.d.ts +15 -0
  16. package/dist/change-feed.d.ts.map +1 -1
  17. package/dist/change-feed.js +4 -0
  18. package/dist/change-feed.js.map +1 -1
  19. package/dist/class.d.ts +33 -0
  20. package/dist/class.d.ts.map +1 -1
  21. package/dist/class.js +77 -0
  22. package/dist/class.js.map +1 -1
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +5 -1
  25. package/dist/collection.js.map +1 -1
  26. package/dist/config.d.ts +3 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/decisions.d.ts +89 -0
  30. package/dist/decisions.d.ts.map +1 -0
  31. package/dist/decisions.js +78 -0
  32. package/dist/decisions.js.map +1 -0
  33. package/dist/errors.d.ts +28 -0
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +36 -1
  36. package/dist/errors.js.map +1 -1
  37. package/dist/generators/changes-route.d.ts.map +1 -1
  38. package/dist/generators/changes-route.js +13 -3
  39. package/dist/generators/changes-route.js.map +1 -1
  40. package/dist/generators/custom-action.d.ts.map +1 -1
  41. package/dist/generators/custom-action.js +2 -1
  42. package/dist/generators/custom-action.js.map +1 -1
  43. package/dist/generators/events-route.d.ts +35 -4
  44. package/dist/generators/events-route.d.ts.map +1 -1
  45. package/dist/generators/events-route.js +78 -14
  46. package/dist/generators/events-route.js.map +1 -1
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +7 -3
  50. package/dist/knowledge-graph.d.ts.map +1 -1
  51. package/dist/knowledge-graph.js +1 -1
  52. package/dist/knowledge-graph.js.map +1 -1
  53. package/dist/manifest/static-manifest.d.ts.map +1 -1
  54. package/dist/manifest/static-manifest.js +17 -1
  55. package/dist/manifest/static-manifest.js.map +1 -1
  56. package/dist/manifest/store.js +1 -1
  57. package/dist/manifest/store.js.map +1 -1
  58. package/dist/manifest.json +20 -1
  59. package/dist/migrations/differ.d.ts +43 -1
  60. package/dist/migrations/differ.d.ts.map +1 -1
  61. package/dist/migrations/differ.js +69 -0
  62. package/dist/migrations/differ.js.map +1 -1
  63. package/dist/object.d.ts +11 -0
  64. package/dist/object.d.ts.map +1 -1
  65. package/dist/object.js +96 -2
  66. package/dist/object.js.map +1 -1
  67. package/dist/registry/shared-state.js +1 -1
  68. package/dist/run-once.d.ts +80 -0
  69. package/dist/run-once.d.ts.map +1 -0
  70. package/dist/run-once.js +311 -0
  71. package/dist/run-once.js.map +1 -0
  72. package/dist/schema/bounded-concurrency.d.ts +31 -0
  73. package/dist/schema/bounded-concurrency.d.ts.map +1 -0
  74. package/dist/schema/bounded-concurrency.js +43 -0
  75. package/dist/schema/bounded-concurrency.js.map +1 -0
  76. package/dist/schema/foreign-key-orphan-report.d.ts +21 -0
  77. package/dist/schema/foreign-key-orphan-report.d.ts.map +1 -1
  78. package/dist/schema/foreign-key-orphan-report.js +90 -55
  79. package/dist/schema/foreign-key-orphan-report.js.map +1 -1
  80. package/dist/smrt-knowledge.json +30 -14
  81. package/dist/system/index.d.ts +1 -0
  82. package/dist/system/index.d.ts.map +1 -1
  83. package/dist/system/index.js +3 -2
  84. package/dist/system/retention.d.ts +53 -4
  85. package/dist/system/retention.d.ts.map +1 -1
  86. package/dist/system/retention.js +67 -14
  87. package/dist/system/retention.js.map +1 -1
  88. package/dist/system/schema.d.ts +33 -1
  89. package/dist/system/schema.d.ts.map +1 -1
  90. package/dist/system/schema.js +60 -3
  91. package/dist/system/schema.js.map +1 -1
  92. package/dist/vite-plugin/changes-route.d.ts.map +1 -1
  93. package/dist/vite-plugin/changes-route.js +21 -5
  94. package/dist/vite-plugin/changes-route.js.map +1 -1
  95. package/dist/vite-plugin/events-route.d.ts.map +1 -1
  96. package/dist/vite-plugin/events-route.js +9 -0
  97. package/dist/vite-plugin/events-route.js.map +1 -1
  98. package/dist/vite-plugin/index.d.ts.map +1 -1
  99. package/dist/vite-plugin/index.js +14 -0
  100. package/dist/vite-plugin/index.js.map +1 -1
  101. package/dist/vite-plugin/worker-registration.d.ts +34 -0
  102. package/dist/vite-plugin/worker-registration.d.ts.map +1 -0
  103. package/dist/vite-plugin/worker-registration.js +108 -0
  104. package/dist/vite-plugin/worker-registration.js.map +1 -0
  105. package/package.json +10 -10
@@ -885,6 +885,51 @@ CREATE TABLE IF NOT EXISTS _smrt_backfills (
885
885
  package_name TEXT
886
886
  );
887
887
  `;
888
+ /** The insert-only claim table backing `runOnce()` (`../run-once.ts`). Browser-safe: retention imports it. */
889
+ var RUN_ONCE_CLAIMS_TABLE = "_smrt_run_once_claims";
890
+ /**
891
+ * Idempotency claims for `runOnce()` (#3080).
892
+ *
893
+ * `claim_key` is the sha256 hex digest of the sorted-key-JSON encoding of
894
+ * `(tenantId, actor, token, contentDigest)` — see `../run-once.ts` for the
895
+ * derivation and why the token alone is not enough (it collapses distinct
896
+ * submissions that reuse a form's token). It is the table's PRIMARY KEY, so
897
+ * a second `INSERT` for the same key always raises a unique-constraint
898
+ * violation instead of silently adopting the first caller's row: exactly the
899
+ * "insert-only against a UNIQUE column" claim the issue asks for.
900
+ *
901
+ * Neither the raw token nor the raw submitted content is stored — only
902
+ * digests — so this table cannot leak submitted business data. `status`
903
+ * moves from `'in_progress'` to `'completed'` inside the SAME transaction
904
+ * that runs the caller's work, so `result` (the caller's JSON-serialized,
905
+ * ready-to-replay return value) and `completed_at` are only ever set once,
906
+ * atomically with whatever else that transaction wrote. If the work throws,
907
+ * `runOnce()` never marks the claim completed, and the transaction that
908
+ * inserted it rolls back the insert along with it — a claim is never left
909
+ * permanently `'in_progress'` by a failed attempt; a retry with the same key
910
+ * can always run again.
911
+ */
912
+ var CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE = `
913
+ CREATE TABLE IF NOT EXISTS _smrt_run_once_claims (
914
+ claim_key TEXT PRIMARY KEY,
915
+ tenant_id TEXT NOT NULL,
916
+ actor TEXT NOT NULL,
917
+ content_digest TEXT NOT NULL,
918
+ status TEXT NOT NULL DEFAULT 'in_progress',
919
+ result TEXT,
920
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
921
+ completed_at TIMESTAMP
922
+ );
923
+
924
+ CREATE INDEX IF NOT EXISTS idx_smrt_run_once_claims_tenant
925
+ ON _smrt_run_once_claims(tenant_id, created_at);
926
+
927
+ -- Retention predicate for pruneRunOnceClaims() (#3080): completed claims are
928
+ -- aged out on completed_at, scoped to status = 'completed' so an in_progress
929
+ -- claim (completed_at IS NULL) is never matched by the sweep.
930
+ CREATE INDEX IF NOT EXISTS idx_smrt_run_once_claims_status_completed
931
+ ON _smrt_run_once_claims(status, completed_at);
932
+ `;
888
933
  /**
889
934
  * All system table creation statements
890
935
  */
@@ -893,6 +938,7 @@ var ALL_SYSTEM_TABLES = [
893
938
  CREATE_SMRT_MIGRATIONS_TABLE,
894
939
  CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE,
895
940
  CREATE_SMRT_BACKFILLS_TABLE,
941
+ CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE,
896
942
  CREATE_SMRT_EMBEDDINGS_TABLE,
897
943
  CREATE_SMRT_DISPATCH_TABLE,
898
944
  CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,
@@ -973,10 +1019,21 @@ function getSystemTableDDLForEngine(ddl, engine) {
973
1019
  * sets — never published, never stamped in any real `_smrt_migrations` table
974
1020
  * — so merging them reuses no version an existing install could have already
975
1021
  * recorded; see the note on {@link SMRT_SCHEMA_DDL_CHECKSUMS}.)
1022
+ *
1023
+ * 1.11.0 adds `_smrt_run_once_claims` (#3080), the insert-only claim table
1024
+ * behind `runOnce()` (`../run-once.ts`).
1025
+ *
1026
+ * 1.11.1 adds `idx_smrt_run_once_claims_status_completed` (#3080), the
1027
+ * `(status, completed_at)` index backing `pruneRunOnceClaims()`'s retention
1028
+ * predicate (`system/retention.ts`).
976
1029
  */
977
- var SMRT_SCHEMA_VERSION = "1.10.1";
978
- Object.freeze({ "1.10.1": "f796ee3b3f7ab8b9dc659ecaa68884ec01277408c3dd6edea540853fce369c16" });
1030
+ var SMRT_SCHEMA_VERSION = "1.11.1";
1031
+ Object.freeze({
1032
+ "1.10.1": "f796ee3b3f7ab8b9dc659ecaa68884ec01277408c3dd6edea540853fce369c16",
1033
+ "1.11.0": "c51351a76c96beb64fa7e5922f78d776367af882ed1ff37229f9e9ffacee8372",
1034
+ "1.11.1": "98112937a3bbb3883c3db1fd5e836751f91a479c1c5da64d4410263a6581ef1f"
1035
+ });
979
1036
  //#endregion
980
- export { ALL_SYSTEM_TABLES, CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION, CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION, CREATE_POSTGRES_SMRT_CHANGES_PENDING_TABLE, CREATE_POSTGRES_SMRT_CHANGES_TABLE, CREATE_SMRT_AI_USAGE_TABLE, CREATE_SMRT_BACKFILLS_TABLE, CREATE_SMRT_CHANGES_TABLE, CREATE_SMRT_CONTEXTS_TABLE, CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE, CREATE_SMRT_DISPATCH_TABLE, CREATE_SMRT_EMBEDDINGS_TABLE, CREATE_SMRT_MIGRATIONS_TABLE, CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, POSTGRES_CHANGE_FEED_DRAIN_BATCH, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME, POSTGRES_CHANGE_FEED_HELPER_MARKER, POSTGRES_CHANGE_FEED_PENDING_TABLE, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES, SMRT_SCHEMA_VERSION, getSystemTableDDL, getSystemTableDDLForEngine };
1037
+ export { ALL_SYSTEM_TABLES, CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION, CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION, CREATE_POSTGRES_SMRT_CHANGES_PENDING_TABLE, CREATE_POSTGRES_SMRT_CHANGES_TABLE, CREATE_SMRT_AI_USAGE_TABLE, CREATE_SMRT_BACKFILLS_TABLE, CREATE_SMRT_CHANGES_TABLE, CREATE_SMRT_CONTEXTS_TABLE, CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE, CREATE_SMRT_DISPATCH_TABLE, CREATE_SMRT_EMBEDDINGS_TABLE, CREATE_SMRT_MIGRATIONS_TABLE, CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE, CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, POSTGRES_CHANGE_FEED_DRAIN_BATCH, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME, POSTGRES_CHANGE_FEED_HELPER_MARKER, POSTGRES_CHANGE_FEED_PENDING_TABLE, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES, RUN_ONCE_CLAIMS_TABLE, SMRT_SCHEMA_VERSION, getSystemTableDDL, getSystemTableDDLForEngine };
981
1038
 
982
1039
  //# sourceMappingURL=schema.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","names":[],"sources":["../../src/system/schema.ts"],"sourcesContent":["/**\n * SMRT System Tables Schema\n *\n * System tables use _smrt_ prefix to avoid conflicts with user tables.\n * All system tables are created in the same database as user data.\n */\n\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\n\n/**\n * Context memory storage\n * Stores remembered context (learned strategies, patterns, selectors) for reuse\n */\nexport const CREATE_SMRT_CONTEXTS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_contexts (\n id TEXT PRIMARY KEY,\n owner_class TEXT NOT NULL,\n owner_id TEXT NOT NULL,\n scope TEXT NOT NULL,\n key TEXT NOT NULL,\n value TEXT,\n metadata TEXT,\n version INTEGER DEFAULT 1,\n confidence REAL DEFAULT 1.0,\n success_count INTEGER DEFAULT 0,\n failure_count INTEGER DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n last_used_at TIMESTAMP,\n expires_at TIMESTAMP,\n UNIQUE(owner_class, owner_id, scope, key, version)\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_owner\n ON _smrt_contexts(owner_class, owner_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_scope\n ON _smrt_contexts(scope);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_confidence\n ON _smrt_contexts(confidence);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_last_used\n ON _smrt_contexts(last_used_at);\n\n-- Retention predicate for pruneExpiredContexts() (#2375). Rows that never\n-- expire carry NULL here, so the index only has to cover the ones that do.\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_expires_at\n ON _smrt_contexts(expires_at);\n`;\n\n/**\n * Schema version tracking\n * Records which SMRT framework versions have been applied\n */\nexport const CREATE_SMRT_MIGRATIONS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_migrations (\n id TEXT PRIMARY KEY,\n version TEXT NOT NULL UNIQUE,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n checksum TEXT\n);\n`;\n\n/**\n * Schema migration tracking\n * Tracks applied schema migrations for idempotency, audit, and rollback\n */\nexport const CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_schema_migrations (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n version TEXT NOT NULL,\n checksum TEXT NOT NULL,\n applied_checksum TEXT,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n execution_time_ms INTEGER,\n package_name TEXT,\n source_file TEXT,\n status TEXT NOT NULL DEFAULT 'pending',\n error_message TEXT,\n attempts INTEGER DEFAULT 0,\n is_reversible INTEGER DEFAULT 1,\n rolled_back_at TIMESTAMP,\n applied_by TEXT,\n batch INTEGER\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_schema_migrations_status\n ON _smrt_schema_migrations(status);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_schema_migrations_applied_at\n ON _smrt_schema_migrations(applied_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_schema_migrations_batch\n ON _smrt_schema_migrations(batch);\n`;\n\n/**\n * Embedding storage for semantic search\n * Stores embedding vectors for SMRT objects to enable vector similarity search\n */\nexport const CREATE_SMRT_EMBEDDINGS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_embeddings (\n id TEXT PRIMARY KEY,\n object_class TEXT NOT NULL,\n object_id TEXT NOT NULL,\n field_name TEXT NOT NULL,\n content_hash TEXT NOT NULL,\n embedding TEXT NOT NULL,\n model TEXT NOT NULL,\n dimensions INTEGER NOT NULL,\n provider TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(object_class, object_id, field_name, model)\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_object\n ON _smrt_embeddings(object_class, object_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_class\n ON _smrt_embeddings(object_class);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_hash\n ON _smrt_embeddings(content_hash);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_model\n ON _smrt_embeddings(model);\n`;\n\n/**\n * Dispatch queue for inter-agent communication\n * Stores dispatch messages for asynchronous agent-to-agent signaling\n */\nexport const CREATE_SMRT_DISPATCH_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_dispatch (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n source TEXT NOT NULL,\n source_id TEXT,\n payload TEXT,\n status TEXT NOT NULL DEFAULT 'pending',\n attempts INTEGER DEFAULT 0,\n last_error TEXT,\n processed_at TIMESTAMP,\n processed_by TEXT,\n target_subscriber TEXT,\n correlation_id TEXT,\n tenant_id TEXT,\n metadata TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_status\n ON _smrt_dispatch(status);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_tenant_id\n ON _smrt_dispatch(tenant_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_type\n ON _smrt_dispatch(type);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_source\n ON _smrt_dispatch(source);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_created\n ON _smrt_dispatch(created_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_target\n ON _smrt_dispatch(target_subscriber);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_correlation\n ON _smrt_dispatch(correlation_id);\n\n-- Retention predicates of DispatchCollection.cleanup() (#2375): completed rows\n-- are aged out on processed_at, failed rows on updated_at. The status-only\n-- index above cannot serve either range.\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_status_processed\n ON _smrt_dispatch(status, processed_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_status_updated\n ON _smrt_dispatch(status, updated_at);\n`;\n\n/**\n * Dispatch subscriptions for persistent handlers\n * Stores subscriptions to dispatch types for agent processing\n */\nexport const CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_dispatch_subscriptions (\n id TEXT PRIMARY KEY,\n signal_type TEXT NOT NULL,\n subscriber TEXT NOT NULL,\n handler TEXT NOT NULL DEFAULT 'handleDispatch',\n delivery TEXT NOT NULL DEFAULT 'compete',\n enabled INTEGER DEFAULT 1,\n tenant_id TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Subscription identity is tenant-scoped (S5 #1398): the same\n-- (signal_type, subscriber) pair may exist independently in different tenants,\n-- so tenant B can no longer overwrite/delete/enable tenant A's subscription.\n-- A named UNIQUE index (rather than an inline UNIQUE constraint) is used so the\n-- compatibility migration can additively reshape existing tables. NULL tenant_id\n-- (global subscriptions) is deduped at the application layer by the NULL-aware\n-- upsert in @happyvertical/sql.\nCREATE UNIQUE INDEX IF NOT EXISTS uq_smrt_dispatch_subs_tenant_signal_subscriber\n ON _smrt_dispatch_subscriptions(tenant_id, signal_type, subscriber);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_subscriber\n ON _smrt_dispatch_subscriptions(subscriber);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_tenant_id\n ON _smrt_dispatch_subscriptions(tenant_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_signal_type\n ON _smrt_dispatch_subscriptions(signal_type);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_enabled\n ON _smrt_dispatch_subscriptions(enabled);\n`;\n\n/**\n * AI usage telemetry storage\n * Stores normalized AI usage records for reporting and billing hooks\n */\nexport const CREATE_SMRT_AI_USAGE_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_ai_usage (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL,\n model TEXT NOT NULL,\n operation TEXT NOT NULL,\n prompt_tokens INTEGER,\n completion_tokens INTEGER,\n total_tokens INTEGER,\n estimated_cost REAL,\n duration INTEGER NOT NULL,\n class_name TEXT,\n tenant_id TEXT,\n tags TEXT,\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_created\n ON _smrt_ai_usage(created_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_class\n ON _smrt_ai_usage(class_name);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_tenant\n ON _smrt_ai_usage(tenant_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_provider_model\n ON _smrt_ai_usage(provider, model);\n\n-- Serves both the tenant-scoped retention predicate of pruneAiUsage() and the\n-- subscriptions billing meter's tenant_id + created_at range scan, neither of\n-- which the single-column indexes above can satisfy (#2375).\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_tenant_created\n ON _smrt_ai_usage(tenant_id, created_at);\n`;\n\n/**\n * Append-only change feed (issue #1758)\n *\n * One row per framework save/delete: monotonic per-database sequence,\n * table, row id, operation (create/update/delete — deletes are tombstones),\n * tenant, timestamp. Written by the change-feed interceptor and read\n * through `getChangesSince()`.\n *\n * `seq` is deliberately a plain BIGINT PRIMARY KEY rather than a native\n * AUTOINCREMENT/identity/serial column: the appender allocates\n * `COALESCE(MAX(seq), 0) + 1` inside the INSERT (with a conflict retry),\n * which keeps committed sequences contiguous so commit order equals\n * sequence order on every engine. Native identity columns allocate before\n * commit, so under concurrent writers on MVCC engines a reader could\n * observe seq N+1 while seq N is still uncommitted and advance its cursor\n * past it — breaking the feed's no-missed-changes cursor guarantee. The\n * plain column also keeps this DDL portable across SQLite, Postgres and\n * DuckDB with no per-engine branching. See `change-feed.ts`.\n *\n * `row_id` is nullable: manual bumps (`bumpChangeFeed`) may record a\n * table-level change without a specific row.\n */\nexport const CREATE_SMRT_CHANGES_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_changes (\n seq BIGINT PRIMARY KEY,\n table_name TEXT NOT NULL,\n row_id TEXT,\n operation TEXT NOT NULL,\n tenant_id TEXT,\n created_at TIMESTAMP NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_table_seq\n ON _smrt_changes(table_name, seq);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_tenant_seq\n ON _smrt_changes(tenant_id, seq);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_created_at\n ON _smrt_changes(created_at);\n`;\n\n/**\n * PostgreSQL-only staging table for change-feed appends made inside a\n * caller-managed transaction (issue #2649).\n *\n * `_smrt_changes.seq` is allocated `COALESCE(MAX(seq), 0) + 1`, so two\n * concurrent appends contend on the same primary-key value and the loser\n * *waits for the winner's transaction to end*. When the winner is a long write\n * transaction that goes on to take row locks the loser already holds, that\n * wait closes a genuine lock cycle and PostgreSQL aborts one side with\n * `40P01` — an ordinary concurrent request killing a legitimate long write.\n *\n * The staging table breaks the cycle by removing the wait: an append issued\n * inside a caller transaction inserts here instead, where its identity value\n * conflicts with nothing and it never waits on another transaction. The row is\n * still fate-shared with the caller (a rollback removes it, exactly as\n * before). A later *drain* — {@link CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION}\n * — moves committed staged rows into `_smrt_changes` with contiguous\n * sequences, so the feed's cursor guarantee is unchanged.\n *\n * PostgreSQL-only on purpose: SQLite and DuckDB do not reach the defect\n * (SQLite serializes writers outright), and keeping the portable DDL untouched\n * keeps the embedded path byte-identical.\n */\nexport const CREATE_POSTGRES_SMRT_CHANGES_PENDING_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_changes_pending (\n pending_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n table_name TEXT NOT NULL,\n row_id TEXT,\n operation TEXT NOT NULL,\n tenant_id TEXT,\n created_at TIMESTAMPTZ NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_pending_table\n ON _smrt_changes_pending(table_name);\n`;\n\n/** Name of the PostgreSQL staging table for deferred change-feed appends. */\nexport const POSTGRES_CHANGE_FEED_PENDING_TABLE = '_smrt_changes_pending';\n\n/** PostgreSQL materialization of the portable change-feed table DDL. */\nexport const CREATE_POSTGRES_SMRT_CHANGES_TABLE =\n CREATE_SMRT_CHANGES_TABLE.replace(/\\bTIMESTAMP\\b/g, 'TIMESTAMPTZ');\n\n/** PostgreSQL helper used to isolate best-effort feed appends (#2026). */\nexport const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME = '_smrt_append_change';\n\n/** PostgreSQL one-statement batch wrapper around the isolated append helper. */\nexport const POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME =\n '_smrt_append_changes';\n\n/** Legacy helper identity used before PostgreSQL Date values became instants. */\nexport const LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp without time zone)`;\n\n/** Exact PostgreSQL identity used for catalog lookup of the append helper. */\nexport const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp with time zone)`;\n\n/** PostgreSQL identity of the one-statement batch append helper. */\nexport const POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME}(jsonb)`;\n\n/**\n * Body marker stamped into both PostgreSQL change-feed helpers (#2649).\n *\n * Existence is not currency: an install can hold a helper of the right name\n * and signature whose *body* predates a fix. The probe in `change-feed.ts`\n * matches this token against `pg_proc.prosrc`, so a stale body is detected and\n * replaced. Bump it whenever either helper's body changes in a way an existing\n * database must pick up.\n */\nexport const POSTGRES_CHANGE_FEED_HELPER_MARKER = 'smrt-change-feed-helpers:v4';\n\n/** PostgreSQL helper that sequences staged change-feed appends (#2649). */\nexport const POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME = '_smrt_drain_changes';\n\n/** Exact PostgreSQL identity used for catalog lookup of the drain helper. */\nexport const POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}(integer)`;\n\n/** How many staged entries one drain call sequences. */\nexport const POSTGRES_CHANGE_FEED_DRAIN_BATCH = 1000;\n\n/**\n * PostgreSQL-only drain: move committed staged appends into `_smrt_changes`.\n *\n * This is where the feed's sequence is actually allocated for entries written\n * inside a caller transaction. It runs in its own short transaction (an\n * autocommit append's, or a reader's `drainChangeFeed()` call), holds no user\n * row locks, and never waits on another transaction:\n *\n * - it only ever sees *committed* staged rows — an in-flight transaction's\n * staged row is invisible under MVCC, so the drain neither reads nor locks\n * it, and it is picked up by a later drain once that transaction commits;\n * - concurrent drains are excluded by a `pg_try_advisory_xact_lock`, which\n * *skips* rather than waits, so a drain can never become an edge in a lock\n * cycle either.\n *\n * Because exactly one drain runs at a time and it numbers rows\n * `COALESCE(MAX(seq), 0) + row_number()` in staged order, committed sequences\n * stay contiguous and no reader can observe seq N before seq N-1 — the change\n * feed's cursor guarantee is preserved verbatim (see `change-feed.ts`).\n *\n * Failures are returned as data, like the append helper, so a drain problem\n * never aborts the caller's transaction. A failed drain leaves the staged rows\n * in place for the next attempt.\n */\nexport const CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION = `\nCREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}(\n p_limit INTEGER DEFAULT ${POSTGRES_CHANGE_FEED_DRAIN_BATCH}\n)\nRETURNS TABLE(\n drained_seq BIGINT,\n drained_table TEXT,\n drained_row_id TEXT,\n drained_operation TEXT,\n drained_tenant_id TEXT,\n error_code TEXT,\n error_message TEXT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $smrt_change_feed_drain$\n-- ${POSTGRES_CHANGE_FEED_HELPER_MARKER}\nDECLARE\n v_base BIGINT;\n v_error_code TEXT;\n v_error_message TEXT;\nBEGIN\n -- EVERY statement below sits inside this exception boundary, preflight\n -- included. The caller may own the surrounding transaction, and an error\n -- escaping this function would abort it — the #2026 contract. A probe that\n -- blocks on a table lock until statement_timeout is exactly that: it is not\n -- the insert, but it aborts the caller just the same.\n BEGIN\n IF to_regclass('_smrt_changes_pending') IS NULL THEN\n RETURN;\n END IF;\n\n -- Never sequence from inside a transaction that has already written. Doing\n -- so would hold freshly allocated sequences uncommitted for the rest of\n -- that transaction, which is precisely the wait edge #2649 removes — a\n -- reader calling getChangesSince() inside a long write transaction must\n -- not put it back. The staged rows keep for the next drain.\n IF pg_current_xact_id_if_assigned() IS NOT NULL THEN\n RETURN;\n END IF;\n\n -- Cheap common case: nothing staged, so an autocommit append pays one\n -- index probe rather than a drain.\n IF NOT EXISTS (SELECT 1 FROM _smrt_changes_pending LIMIT 1) THEN\n RETURN;\n END IF;\n\n -- Try, never wait: a drain that waited could become an edge in a cycle.\n IF NOT pg_try_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('change-feed-drain')\n ) THEN\n RETURN;\n END IF;\n\n SELECT COALESCE(MAX(changes.seq), 0) INTO v_base FROM _smrt_changes AS changes;\n\n RETURN QUERY\n WITH removed AS (\n DELETE FROM _smrt_changes_pending AS pending\n WHERE pending.pending_id IN (\n SELECT candidate.pending_id\n FROM _smrt_changes_pending AS candidate\n ORDER BY candidate.pending_id\n LIMIT GREATEST(COALESCE(p_limit, ${POSTGRES_CHANGE_FEED_DRAIN_BATCH}), 1)\n )\n RETURNING\n pending.pending_id,\n pending.table_name,\n pending.row_id,\n pending.operation,\n pending.tenant_id,\n pending.created_at\n ),\n numbered AS (\n SELECT\n removed.*,\n v_base + row_number() OVER (ORDER BY removed.pending_id) AS new_seq\n FROM removed\n ),\n inserted AS (\n INSERT INTO _smrt_changes (\n seq,\n table_name,\n row_id,\n operation,\n tenant_id,\n created_at\n )\n SELECT\n numbered.new_seq,\n numbered.table_name,\n numbered.row_id,\n numbered.operation,\n numbered.tenant_id,\n numbered.created_at\n FROM numbered\n RETURNING\n _smrt_changes.seq,\n _smrt_changes.table_name,\n _smrt_changes.row_id,\n _smrt_changes.operation,\n _smrt_changes.tenant_id\n )\n SELECT\n inserted.seq,\n inserted.table_name,\n inserted.row_id,\n inserted.operation,\n inserted.tenant_id,\n NULL::TEXT,\n NULL::TEXT\n FROM inserted\n ORDER BY inserted.seq;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT\n NULL::BIGINT,\n NULL::TEXT,\n NULL::TEXT,\n NULL::TEXT,\n NULL::TEXT,\n v_error_code,\n v_error_message;\n END;\nEND;\n$smrt_change_feed_drain$;\n`;\n\n/**\n * PostgreSQL-only change-feed append function.\n *\n * A PL/pgSQL block with an EXCEPTION handler runs its body in an internal\n * subtransaction. Returning SQLSTATE as data lets the caller log or retry a\n * failed best-effort append without leaving its surrounding transaction in\n * PostgreSQL's aborted (25P02) state. This statement contains dollar-quoted\n * semicolons, so callers must execute it whole rather than adding it to\n * {@link ALL_SYSTEM_TABLES}, whose portable DDL entries are semicolon-split.\n */\nexport const CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = `\nCREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(\n p_table_name TEXT,\n p_row_id TEXT,\n p_operation TEXT,\n p_tenant_id TEXT,\n p_created_at TIMESTAMPTZ\n)\nRETURNS TABLE(\n allocated_seq BIGINT,\n error_code TEXT,\n error_message TEXT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $smrt_change_feed$\n-- ${POSTGRES_CHANGE_FEED_HELPER_MARKER}\nDECLARE\n v_seq BIGINT;\n v_error_code TEXT;\n v_error_message TEXT;\n v_deferred BOOLEAN;\nBEGIN\n -- Has the caller already written in this transaction?\n --\n -- PostgreSQL assigns a transaction id at the first write, so a non-NULL\n -- pg_current_xact_id_if_assigned() means the caller may be holding row\n -- locks. Allocating the feed head here would then make a concurrent appender\n -- wait for this whole transaction to end — the wait edge that closes the\n -- #2649 cycle. An autocommit append reports NULL: its caller's row write\n -- committed as its own statement, so it holds no row locks and its inline\n -- allocation cannot be part of a cycle. (Requires PostgreSQL 13+.)\n --\n -- This is exactly the condition that makes a cycle possible, not an\n -- approximation of \"inside BEGIN\": a transaction that appends *before* its\n -- first write still allocates inline, but it holds no row locks at that\n -- moment, so nothing that waits on its feed row can also be waited on by it.\n -- Preflight inside its own exception boundary for the same reason the drain\n -- wraps its own: an error here would abort a caller-owned transaction\n -- (#2026), and a catalog probe is still a statement that can fail.\n BEGIN\n v_deferred := pg_current_xact_id_if_assigned() IS NOT NULL\n AND to_regclass('_smrt_changes_pending') IS NOT NULL;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::BIGINT, v_error_code, v_error_message;\n RETURN;\n END;\n\n IF v_deferred THEN\n BEGIN\n -- Staged, not sequenced: an identity value conflicts with nothing, so\n -- this insert never waits on another transaction. It stays fate-shared\n -- with the caller (rollback removes it) and is moved into\n -- _smrt_changes, in order and with a contiguous sequence, by the next\n -- drain after the caller commits.\n INSERT INTO _smrt_changes_pending (\n table_name,\n row_id,\n operation,\n tenant_id,\n created_at\n )\n VALUES (\n p_table_name,\n p_row_id,\n p_operation,\n p_tenant_id,\n p_created_at\n );\n\n -- NULL sequence with NULL error code is the staged marker.\n RETURN QUERY SELECT NULL::BIGINT, NULL::TEXT, NULL::TEXT;\n RETURN;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::BIGINT, v_error_code, v_error_message;\n RETURN;\n END;\n END IF;\n\n -- Autocommit append. The caller's own row write already committed as its own\n -- statement, so this transaction holds no user row locks and allocating the\n -- head here cannot join a lock cycle.\n --\n -- This deliberately does NOT drain: a drain performed here would sequence\n -- staged entries that the JavaScript caller never sees, so no live SSE\n -- signal would be published for them while this append's own signal carries\n -- a HIGHER sequence -- and an EventSource that stores that id as its\n -- Last-Event-ID resumes above them and never receives them. Draining is\n -- driven from JavaScript (drainChangeFeed) so every sequenced entry gets\n -- its signal.\n BEGIN\n INSERT INTO _smrt_changes (\n seq,\n table_name,\n row_id,\n operation,\n tenant_id,\n created_at\n )\n SELECT\n COALESCE(MAX(changes.seq), 0) + 1,\n p_table_name,\n p_row_id,\n p_operation,\n p_tenant_id,\n p_created_at\n FROM _smrt_changes AS changes\n RETURNING _smrt_changes.seq INTO v_seq;\n\n RETURN QUERY SELECT v_seq, NULL::TEXT, NULL::TEXT;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::BIGINT, v_error_code, v_error_message;\n END;\nEND;\n$smrt_change_feed$;\n`;\n\n/**\n * PostgreSQL batch append helper. It makes the staged/direct decision before\n * its own writes, then appends every element through that one path. This is\n * necessary because a direct first insert assigns the transaction id that\n * later entries must not mistake for a caller-owned write transaction.\n */\nexport const CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION = `\nCREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME}(\n p_entries JSONB\n)\nRETURNS TABLE(\n entry_index INTEGER,\n allocated_seq BIGINT,\n error_code TEXT,\n error_message TEXT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $smrt_change_feed_batch$\n-- ${POSTGRES_CHANGE_FEED_HELPER_MARKER}\nDECLARE\n v_deferred BOOLEAN;\n v_error_code TEXT;\n v_error_message TEXT;\nBEGIN\n BEGIN\n v_deferred := pg_current_xact_id_if_assigned() IS NOT NULL\n AND to_regclass('_smrt_changes_pending') IS NOT NULL;\n\n IF v_deferred THEN\n RETURN QUERY\n WITH input AS (\n SELECT entries.ordinality::INTEGER - 1 AS entry_index, entries.value\n FROM jsonb_array_elements(COALESCE(p_entries, '[]'::jsonb))\n WITH ORDINALITY AS entries(value, ordinality)\n ), staged AS (\n INSERT INTO _smrt_changes_pending (\n table_name, row_id, operation, tenant_id, created_at\n )\n SELECT\n input.value->>'table',\n input.value->>'rowId',\n input.value->>'operation',\n input.value->>'tenantId',\n (input.value->>'timestamp')::TIMESTAMPTZ\n FROM input\n ORDER BY input.entry_index\n RETURNING 1\n )\n SELECT input.entry_index, NULL::BIGINT, NULL::TEXT, NULL::TEXT\n FROM input\n ORDER BY input.entry_index;\n RETURN;\n END IF;\n\n RETURN QUERY\n WITH input AS (\n SELECT entries.ordinality::INTEGER - 1 AS entry_index, entries.value\n FROM jsonb_array_elements(COALESCE(p_entries, '[]'::jsonb))\n WITH ORDINALITY AS entries(value, ordinality)\n ), numbered AS (\n SELECT\n input.*,\n COALESCE((SELECT MAX(seq) FROM _smrt_changes), 0)\n + row_number() OVER (ORDER BY input.entry_index) AS new_seq\n FROM input\n ), inserted AS (\n INSERT INTO _smrt_changes (\n seq, table_name, row_id, operation, tenant_id, created_at\n )\n SELECT\n numbered.new_seq,\n numbered.value->>'table',\n numbered.value->>'rowId',\n numbered.value->>'operation',\n numbered.value->>'tenantId',\n (numbered.value->>'timestamp')::TIMESTAMPTZ\n FROM numbered\n ORDER BY numbered.entry_index\n RETURNING seq\n )\n SELECT\n numbered.entry_index,\n numbered.new_seq,\n NULL::TEXT,\n NULL::TEXT\n FROM numbered\n ORDER BY numbered.entry_index;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::INTEGER, NULL::BIGINT, v_error_code, v_error_message;\n END;\nEND;\n$smrt_change_feed_batch$;\n`;\n\nconst POSTGRES_CHANGE_FEED_PENDING_DDL =\n CREATE_POSTGRES_SMRT_CHANGES_PENDING_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0)\n .map(\n (statement) => `EXECUTE $smrt_change_feed_pending_ddl$\n${statement}\n$smrt_change_feed_pending_ddl$;`,\n )\n .join('\\n');\n\n/**\n * Serialize PostgreSQL helper replacement inside one server-side statement.\n *\n * The transaction-scoped advisory lock prevents concurrent bootstraps from\n * racing on PostgreSQL's `pg_proc` uniqueness constraint. The nested dollar\n * quote keeps the complete function DDL atomic from the client's perspective.\n */\nexport const REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = `\nDO $smrt_replace_change_feed$\nBEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n${POSTGRES_CHANGE_FEED_PENDING_DDL}\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_drain_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION.trim()}\n$smrt_change_feed_drain_ddl$;\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n EXECUTE $smrt_change_feed_batch_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION.trim()}\n$smrt_change_feed_batch_ddl$;\nEND;\n$smrt_replace_change_feed$;\n`;\n\n/**\n * Install the PostgreSQL helpers only when missing, serialized server-side.\n *\n * A client-side catalog probe remains the fast path for already-initialized\n * read handles. This guarded statement is the cold-path race boundary: both\n * the advisory lock and the post-lock catalog check run before function DDL.\n *\n * The staging table and the drain helper (#2649) are created unconditionally\n * (`IF NOT EXISTS` / `CREATE OR REPLACE`) because an install that already has\n * the append helper from an older SMRT may still be missing them; the append\n * helper itself is replaced when the staging table was absent, which is how an\n * upgraded database picks up the deferring version.\n */\nexport const ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = `\nDO $smrt_ensure_change_feed$\nDECLARE\n v_append_stale BOOLEAN;\nBEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n v_append_stale := to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') IS NULL\n OR NOT EXISTS (\n SELECT 1\n FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n ) OR NOT EXISTS (\n SELECT 1 FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n );\n${POSTGRES_CHANGE_FEED_PENDING_DDL}\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_drain_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION.trim()}\n$smrt_change_feed_drain_ddl$;\n IF v_append_stale THEN\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n EXECUTE $smrt_change_feed_batch_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION.trim()}\n$smrt_change_feed_batch_ddl$;\n END IF;\nEND;\n$smrt_ensure_change_feed$;\n`;\n\nconst POSTGRES_CHANGE_FEED_SCHEMA_DDL =\n CREATE_POSTGRES_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0)\n .map(\n (statement) => `EXECUTE $smrt_change_feed_schema_ddl$\n${statement}\n$smrt_change_feed_schema_ddl$;`,\n )\n .join('\\n');\n\n/**\n * Install the complete PostgreSQL change-feed schema under the bootstrap lock.\n *\n * The lock is deliberately acquired before table or index DDL. Framework\n * bootstrap uses the same ordering, so a raw-handle cold start cannot retain\n * catalog locks while waiting behind a framework bootstrap transaction.\n */\nexport const ENSURE_POSTGRES_CHANGE_FEED_SCHEMA = `\nDO $smrt_ensure_change_feed_schema$\nDECLARE\n v_append_stale BOOLEAN;\nBEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n v_append_stale := to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') IS NULL\n OR NOT EXISTS (\n SELECT 1\n FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n ) OR NOT EXISTS (\n SELECT 1 FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n );\n${POSTGRES_CHANGE_FEED_SCHEMA_DDL}\n${POSTGRES_CHANGE_FEED_PENDING_DDL}\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_drain_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION.trim()}\n$smrt_change_feed_drain_ddl$;\n IF v_append_stale THEN\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n EXECUTE $smrt_change_feed_batch_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION.trim()}\n$smrt_change_feed_batch_ddl$;\n END IF;\nEND;\n$smrt_ensure_change_feed_schema$;\n`;\n\n/**\n * Data backfill tracking\n *\n * Distinct from `_smrt_schema_migrations` — backfills are app-specific\n * data corrections (slug rewrites, model splits, lookup-table seeds) that\n * don't have schema diffs or rollback semantics. Apps register backfills\n * by name and the tracker handles idempotency.\n */\nexport const CREATE_SMRT_BACKFILLS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_backfills (\n name TEXT PRIMARY KEY,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n package_name TEXT\n);\n`;\n\n/**\n * All system table creation statements\n */\nexport const ALL_SYSTEM_TABLES = [\n CREATE_SMRT_CONTEXTS_TABLE,\n CREATE_SMRT_MIGRATIONS_TABLE,\n CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE,\n CREATE_SMRT_BACKFILLS_TABLE,\n CREATE_SMRT_EMBEDDINGS_TABLE,\n CREATE_SMRT_DISPATCH_TABLE,\n CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,\n CREATE_SMRT_AI_USAGE_TABLE,\n CREATE_SMRT_CHANGES_TABLE,\n];\n\n/**\n * Framework-owned tables that are backed by `@smrt()` models rather than by\n * {@link ALL_SYSTEM_TABLES}, and whose write volume disqualifies them from the\n * change feed.\n *\n * This is the jobs runner's own state: the claim loop and the forge projection\n * poll every second and rewrite these rows several times per job, so feeding\n * them into an append-only log whose pruning is not scheduled (issue #2375)\n * would dominate it with data no client syncs.\n *\n * The list is deliberately short and biased toward *observing*. A table wrongly\n * excluded is an invisible bug — exactly the one issue #2376 fixes — while a\n * table wrongly included costs a few extra feed rows. Everything else keeping\n * the `_smrt_` prefix (feature flags, prompt overrides, subscription plans,\n * field policies, agent and report schedules, report state) is observed.\n */\nexport const FRAMEWORK_OPERATIONAL_TABLES: readonly string[] = [\n '_smrt_jobs',\n '_smrt_job_events',\n '_smrt_workers',\n '_smrt_forge_deliveries',\n '_smrt_forge_projection_checkpoints',\n];\n\n/**\n * Framework tables that are backed by `@smrt()` models *and* reshaped by\n * `system/compatibility.ts`.\n *\n * They are dual-owned: `db:migrate` creates them from the jobs manifest, and\n * the compatibility pass adds columns/indexes that predate the manifest. On a\n * fresh install the manifest wins the race — bootstrap runs before the tables\n * exist — so their compatibility pass is deferred and re-checked until the\n * tables show up (issue #2376).\n */\nexport const DEFERRED_COMPATIBILITY_TABLES: readonly string[] = [\n '_smrt_jobs',\n '_smrt_job_events',\n];\n\n/**\n * System tables SMRT used to create on every database and no longer does.\n *\n * `_smrt_signals` never had a writer and `_smrt_registry` was only written by\n * an `ObjectRegistry.persistToDatabase()` helper that nothing ever called, so\n * both were empty on every deployment. New databases no longer get them.\n * Existing databases keep the empty tables until an operator drops them:\n *\n * ```sql\n * DROP TABLE IF EXISTS _smrt_signals;\n * DROP TABLE IF EXISTS _smrt_registry;\n * ```\n *\n * The framework deliberately does not issue those drops itself — dropping a\n * table is not reversible and a deployment may have repurposed the name.\n */\nexport const RETIRED_SYSTEM_TABLES: readonly string[] = [\n '_smrt_registry',\n '_smrt_signals',\n];\n\n/**\n * Materialize the portable system-table schema for a target engine.\n *\n * PostgreSQL must use TIMESTAMPTZ because every system Date is an instant.\n * PostgreSQL and DuckDB also materialize integer storage as BIGINT, matching\n * application-table DDL; SQLite keeps its variable-width INTEGER.\n */\nexport function getSystemTableDDL(engine: DatabaseEngine): string[] {\n return ALL_SYSTEM_TABLES.map((ddl) =>\n getSystemTableDDLForEngine(ddl, engine),\n );\n}\n\n/** Materialize one portable system-table statement for a target engine. */\nexport function getSystemTableDDLForEngine(\n ddl: string,\n engine: DatabaseEngine,\n): string {\n const integerDdl =\n engine === 'postgres' || engine === 'duckdb' || engine === 'json'\n ? ddl.replace(/\\bINTEGER\\b/g, 'BIGINT')\n : ddl;\n return engine === 'postgres'\n ? integerDdl.replace(/\\bTIMESTAMP\\b/g, 'TIMESTAMPTZ')\n : integerDdl;\n}\n\n/**\n * Current SMRT system schema version.\n *\n * `bootstrapSystemTables()` skips replaying system DDL once this exact version\n * is recorded in `_smrt_migrations`, so every change to {@link ALL_SYSTEM_TABLES}\n * must bump it — otherwise existing databases never see the change. New\n * *columns* additionally need an `addColumnIfMissing()` entry in\n * `system/compatibility.ts`, because `CREATE TABLE IF NOT EXISTS` is a no-op on\n * a table that already exists.\n *\n * 1.10.1 retires the never-written `_smrt_registry` / `_smrt_signals` tables\n * (see {@link RETIRED_SYSTEM_TABLES}) and adds the retention-predicate indexes\n * from #2375 (`_smrt_contexts.expires_at`, `_smrt_ai_usage(tenant_id,\n * created_at)`, `_smrt_dispatch(status, processed_at)` and\n * `(status, updated_at)`) in the same replay. (`1.10.0` briefly existed only\n * on two unreleased, unmerged branches with two different, incompatible DDL\n * sets — never published, never stamped in any real `_smrt_migrations` table\n * — so merging them reuses no version an existing install could have already\n * recorded; see the note on {@link SMRT_SCHEMA_DDL_CHECKSUMS}.)\n */\nexport const SMRT_SCHEMA_VERSION = '1.10.1';\n\n/**\n * Canonical form of the system DDL that {@link SMRT_SCHEMA_DDL_CHECKSUMS} covers.\n *\n * Whitespace is normalized so reformatting alone does not trip the guard while\n * any token change does.\n */\nexport function getSystemSchemaChecksumInput(): string {\n return ALL_SYSTEM_TABLES.join('\\n').replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * SHA-256 of {@link getSystemSchemaChecksumInput} for each system schema\n * version, oldest first.\n *\n * The discipline that keeps fresh installs and upgraded installs identical was\n * documented but unenforced (issue #2376): a contributor could edit the DDL and\n * ship it without bumping the version, and no existing database would ever\n * apply the change. `system-schema-evolution.test.ts` recomputes the current\n * hash and requires it to equal this map's entry for\n * {@link SMRT_SCHEMA_VERSION}.\n *\n * It is a *history*, not a single constant, so the guard cannot be silenced by\n * overwriting one value: every recorded hash must stay distinct, which means a\n * changed DDL has nowhere to be recorded except under a new version key. Adding\n * that key is the bump. This is also why merging two branches that each\n * independently bumped to the same version string (both `1.10.0`, covering two\n * different DDL sets — #2376's table retirement and #2375's retention indexes)\n * must land under a *new* key rather than picking a \"final\" checksum for the\n * shared number: nothing published or otherwise reachable ever stamped\n * `1.10.0`, so recording the merged DDL as `1.10.1` costs nothing and removes\n * any doubt about a version string meaning two different things to two\n * installs.\n *\n * Entries before 1.10.1 are not recorded — the guard starts here.\n */\nexport const SMRT_SCHEMA_DDL_CHECKSUMS: Readonly<Record<string, string>> =\n Object.freeze({\n '1.10.1':\n 'f796ee3b3f7ab8b9dc659ecaa68884ec01277408c3dd6edea540853fce369c16',\n });\n"],"mappings":"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"}
1
+ {"version":3,"file":"schema.js","names":[],"sources":["../../src/system/schema.ts"],"sourcesContent":["/**\n * SMRT System Tables Schema\n *\n * System tables use _smrt_ prefix to avoid conflicts with user tables.\n * All system tables are created in the same database as user data.\n */\n\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\n\n/**\n * Context memory storage\n * Stores remembered context (learned strategies, patterns, selectors) for reuse\n */\nexport const CREATE_SMRT_CONTEXTS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_contexts (\n id TEXT PRIMARY KEY,\n owner_class TEXT NOT NULL,\n owner_id TEXT NOT NULL,\n scope TEXT NOT NULL,\n key TEXT NOT NULL,\n value TEXT,\n metadata TEXT,\n version INTEGER DEFAULT 1,\n confidence REAL DEFAULT 1.0,\n success_count INTEGER DEFAULT 0,\n failure_count INTEGER DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n last_used_at TIMESTAMP,\n expires_at TIMESTAMP,\n UNIQUE(owner_class, owner_id, scope, key, version)\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_owner\n ON _smrt_contexts(owner_class, owner_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_scope\n ON _smrt_contexts(scope);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_confidence\n ON _smrt_contexts(confidence);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_last_used\n ON _smrt_contexts(last_used_at);\n\n-- Retention predicate for pruneExpiredContexts() (#2375). Rows that never\n-- expire carry NULL here, so the index only has to cover the ones that do.\nCREATE INDEX IF NOT EXISTS idx_smrt_contexts_expires_at\n ON _smrt_contexts(expires_at);\n`;\n\n/**\n * Schema version tracking\n * Records which SMRT framework versions have been applied\n */\nexport const CREATE_SMRT_MIGRATIONS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_migrations (\n id TEXT PRIMARY KEY,\n version TEXT NOT NULL UNIQUE,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n checksum TEXT\n);\n`;\n\n/**\n * Schema migration tracking\n * Tracks applied schema migrations for idempotency, audit, and rollback\n */\nexport const CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_schema_migrations (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n version TEXT NOT NULL,\n checksum TEXT NOT NULL,\n applied_checksum TEXT,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n execution_time_ms INTEGER,\n package_name TEXT,\n source_file TEXT,\n status TEXT NOT NULL DEFAULT 'pending',\n error_message TEXT,\n attempts INTEGER DEFAULT 0,\n is_reversible INTEGER DEFAULT 1,\n rolled_back_at TIMESTAMP,\n applied_by TEXT,\n batch INTEGER\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_schema_migrations_status\n ON _smrt_schema_migrations(status);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_schema_migrations_applied_at\n ON _smrt_schema_migrations(applied_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_schema_migrations_batch\n ON _smrt_schema_migrations(batch);\n`;\n\n/**\n * Embedding storage for semantic search\n * Stores embedding vectors for SMRT objects to enable vector similarity search\n */\nexport const CREATE_SMRT_EMBEDDINGS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_embeddings (\n id TEXT PRIMARY KEY,\n object_class TEXT NOT NULL,\n object_id TEXT NOT NULL,\n field_name TEXT NOT NULL,\n content_hash TEXT NOT NULL,\n embedding TEXT NOT NULL,\n model TEXT NOT NULL,\n dimensions INTEGER NOT NULL,\n provider TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(object_class, object_id, field_name, model)\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_object\n ON _smrt_embeddings(object_class, object_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_class\n ON _smrt_embeddings(object_class);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_hash\n ON _smrt_embeddings(content_hash);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_embeddings_model\n ON _smrt_embeddings(model);\n`;\n\n/**\n * Dispatch queue for inter-agent communication\n * Stores dispatch messages for asynchronous agent-to-agent signaling\n */\nexport const CREATE_SMRT_DISPATCH_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_dispatch (\n id TEXT PRIMARY KEY,\n type TEXT NOT NULL,\n source TEXT NOT NULL,\n source_id TEXT,\n payload TEXT,\n status TEXT NOT NULL DEFAULT 'pending',\n attempts INTEGER DEFAULT 0,\n last_error TEXT,\n processed_at TIMESTAMP,\n processed_by TEXT,\n target_subscriber TEXT,\n correlation_id TEXT,\n tenant_id TEXT,\n metadata TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_status\n ON _smrt_dispatch(status);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_tenant_id\n ON _smrt_dispatch(tenant_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_type\n ON _smrt_dispatch(type);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_source\n ON _smrt_dispatch(source);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_created\n ON _smrt_dispatch(created_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_target\n ON _smrt_dispatch(target_subscriber);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_correlation\n ON _smrt_dispatch(correlation_id);\n\n-- Retention predicates of DispatchCollection.cleanup() (#2375): completed rows\n-- are aged out on processed_at, failed rows on updated_at. The status-only\n-- index above cannot serve either range.\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_status_processed\n ON _smrt_dispatch(status, processed_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_status_updated\n ON _smrt_dispatch(status, updated_at);\n`;\n\n/**\n * Dispatch subscriptions for persistent handlers\n * Stores subscriptions to dispatch types for agent processing\n */\nexport const CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_dispatch_subscriptions (\n id TEXT PRIMARY KEY,\n signal_type TEXT NOT NULL,\n subscriber TEXT NOT NULL,\n handler TEXT NOT NULL DEFAULT 'handleDispatch',\n delivery TEXT NOT NULL DEFAULT 'compete',\n enabled INTEGER DEFAULT 1,\n tenant_id TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Subscription identity is tenant-scoped (S5 #1398): the same\n-- (signal_type, subscriber) pair may exist independently in different tenants,\n-- so tenant B can no longer overwrite/delete/enable tenant A's subscription.\n-- A named UNIQUE index (rather than an inline UNIQUE constraint) is used so the\n-- compatibility migration can additively reshape existing tables. NULL tenant_id\n-- (global subscriptions) is deduped at the application layer by the NULL-aware\n-- upsert in @happyvertical/sql.\nCREATE UNIQUE INDEX IF NOT EXISTS uq_smrt_dispatch_subs_tenant_signal_subscriber\n ON _smrt_dispatch_subscriptions(tenant_id, signal_type, subscriber);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_subscriber\n ON _smrt_dispatch_subscriptions(subscriber);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_tenant_id\n ON _smrt_dispatch_subscriptions(tenant_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_signal_type\n ON _smrt_dispatch_subscriptions(signal_type);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_enabled\n ON _smrt_dispatch_subscriptions(enabled);\n`;\n\n/**\n * AI usage telemetry storage\n * Stores normalized AI usage records for reporting and billing hooks\n */\nexport const CREATE_SMRT_AI_USAGE_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_ai_usage (\n id TEXT PRIMARY KEY,\n provider TEXT NOT NULL,\n model TEXT NOT NULL,\n operation TEXT NOT NULL,\n prompt_tokens INTEGER,\n completion_tokens INTEGER,\n total_tokens INTEGER,\n estimated_cost REAL,\n duration INTEGER NOT NULL,\n class_name TEXT,\n tenant_id TEXT,\n tags TEXT,\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_created\n ON _smrt_ai_usage(created_at);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_class\n ON _smrt_ai_usage(class_name);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_tenant\n ON _smrt_ai_usage(tenant_id);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_provider_model\n ON _smrt_ai_usage(provider, model);\n\n-- Serves both the tenant-scoped retention predicate of pruneAiUsage() and the\n-- subscriptions billing meter's tenant_id + created_at range scan, neither of\n-- which the single-column indexes above can satisfy (#2375).\nCREATE INDEX IF NOT EXISTS idx_smrt_ai_usage_tenant_created\n ON _smrt_ai_usage(tenant_id, created_at);\n`;\n\n/**\n * Append-only change feed (issue #1758)\n *\n * One row per framework save/delete: monotonic per-database sequence,\n * table, row id, operation (create/update/delete — deletes are tombstones),\n * tenant, timestamp. Written by the change-feed interceptor and read\n * through `getChangesSince()`.\n *\n * `seq` is deliberately a plain BIGINT PRIMARY KEY rather than a native\n * AUTOINCREMENT/identity/serial column: the appender allocates\n * `COALESCE(MAX(seq), 0) + 1` inside the INSERT (with a conflict retry),\n * which keeps committed sequences contiguous so commit order equals\n * sequence order on every engine. Native identity columns allocate before\n * commit, so under concurrent writers on MVCC engines a reader could\n * observe seq N+1 while seq N is still uncommitted and advance its cursor\n * past it — breaking the feed's no-missed-changes cursor guarantee. The\n * plain column also keeps this DDL portable across SQLite, Postgres and\n * DuckDB with no per-engine branching. See `change-feed.ts`.\n *\n * `row_id` is nullable: manual bumps (`bumpChangeFeed`) may record a\n * table-level change without a specific row.\n */\nexport const CREATE_SMRT_CHANGES_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_changes (\n seq BIGINT PRIMARY KEY,\n table_name TEXT NOT NULL,\n row_id TEXT,\n operation TEXT NOT NULL,\n tenant_id TEXT,\n created_at TIMESTAMP NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_table_seq\n ON _smrt_changes(table_name, seq);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_tenant_seq\n ON _smrt_changes(tenant_id, seq);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_created_at\n ON _smrt_changes(created_at);\n`;\n\n/**\n * PostgreSQL-only staging table for change-feed appends made inside a\n * caller-managed transaction (issue #2649).\n *\n * `_smrt_changes.seq` is allocated `COALESCE(MAX(seq), 0) + 1`, so two\n * concurrent appends contend on the same primary-key value and the loser\n * *waits for the winner's transaction to end*. When the winner is a long write\n * transaction that goes on to take row locks the loser already holds, that\n * wait closes a genuine lock cycle and PostgreSQL aborts one side with\n * `40P01` — an ordinary concurrent request killing a legitimate long write.\n *\n * The staging table breaks the cycle by removing the wait: an append issued\n * inside a caller transaction inserts here instead, where its identity value\n * conflicts with nothing and it never waits on another transaction. The row is\n * still fate-shared with the caller (a rollback removes it, exactly as\n * before). A later *drain* — {@link CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION}\n * — moves committed staged rows into `_smrt_changes` with contiguous\n * sequences, so the feed's cursor guarantee is unchanged.\n *\n * PostgreSQL-only on purpose: SQLite and DuckDB do not reach the defect\n * (SQLite serializes writers outright), and keeping the portable DDL untouched\n * keeps the embedded path byte-identical.\n */\nexport const CREATE_POSTGRES_SMRT_CHANGES_PENDING_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_changes_pending (\n pending_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n table_name TEXT NOT NULL,\n row_id TEXT,\n operation TEXT NOT NULL,\n tenant_id TEXT,\n created_at TIMESTAMPTZ NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_changes_pending_table\n ON _smrt_changes_pending(table_name);\n`;\n\n/** Name of the PostgreSQL staging table for deferred change-feed appends. */\nexport const POSTGRES_CHANGE_FEED_PENDING_TABLE = '_smrt_changes_pending';\n\n/** PostgreSQL materialization of the portable change-feed table DDL. */\nexport const CREATE_POSTGRES_SMRT_CHANGES_TABLE =\n CREATE_SMRT_CHANGES_TABLE.replace(/\\bTIMESTAMP\\b/g, 'TIMESTAMPTZ');\n\n/** PostgreSQL helper used to isolate best-effort feed appends (#2026). */\nexport const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME = '_smrt_append_change';\n\n/** PostgreSQL one-statement batch wrapper around the isolated append helper. */\nexport const POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME =\n '_smrt_append_changes';\n\n/** Legacy helper identity used before PostgreSQL Date values became instants. */\nexport const LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp without time zone)`;\n\n/** Exact PostgreSQL identity used for catalog lookup of the append helper. */\nexport const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp with time zone)`;\n\n/** PostgreSQL identity of the one-statement batch append helper. */\nexport const POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME}(jsonb)`;\n\n/**\n * Body marker stamped into both PostgreSQL change-feed helpers (#2649).\n *\n * Existence is not currency: an install can hold a helper of the right name\n * and signature whose *body* predates a fix. The probe in `change-feed.ts`\n * matches this token against `pg_proc.prosrc`, so a stale body is detected and\n * replaced. Bump it whenever either helper's body changes in a way an existing\n * database must pick up.\n */\nexport const POSTGRES_CHANGE_FEED_HELPER_MARKER = 'smrt-change-feed-helpers:v4';\n\n/** PostgreSQL helper that sequences staged change-feed appends (#2649). */\nexport const POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME = '_smrt_drain_changes';\n\n/** Exact PostgreSQL identity used for catalog lookup of the drain helper. */\nexport const POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}(integer)`;\n\n/** How many staged entries one drain call sequences. */\nexport const POSTGRES_CHANGE_FEED_DRAIN_BATCH = 1000;\n\n/**\n * PostgreSQL-only drain: move committed staged appends into `_smrt_changes`.\n *\n * This is where the feed's sequence is actually allocated for entries written\n * inside a caller transaction. It runs in its own short transaction (an\n * autocommit append's, or a reader's `drainChangeFeed()` call), holds no user\n * row locks, and never waits on another transaction:\n *\n * - it only ever sees *committed* staged rows — an in-flight transaction's\n * staged row is invisible under MVCC, so the drain neither reads nor locks\n * it, and it is picked up by a later drain once that transaction commits;\n * - concurrent drains are excluded by a `pg_try_advisory_xact_lock`, which\n * *skips* rather than waits, so a drain can never become an edge in a lock\n * cycle either.\n *\n * Because exactly one drain runs at a time and it numbers rows\n * `COALESCE(MAX(seq), 0) + row_number()` in staged order, committed sequences\n * stay contiguous and no reader can observe seq N before seq N-1 — the change\n * feed's cursor guarantee is preserved verbatim (see `change-feed.ts`).\n *\n * Failures are returned as data, like the append helper, so a drain problem\n * never aborts the caller's transaction. A failed drain leaves the staged rows\n * in place for the next attempt.\n */\nexport const CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION = `\nCREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}(\n p_limit INTEGER DEFAULT ${POSTGRES_CHANGE_FEED_DRAIN_BATCH}\n)\nRETURNS TABLE(\n drained_seq BIGINT,\n drained_table TEXT,\n drained_row_id TEXT,\n drained_operation TEXT,\n drained_tenant_id TEXT,\n error_code TEXT,\n error_message TEXT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $smrt_change_feed_drain$\n-- ${POSTGRES_CHANGE_FEED_HELPER_MARKER}\nDECLARE\n v_base BIGINT;\n v_error_code TEXT;\n v_error_message TEXT;\nBEGIN\n -- EVERY statement below sits inside this exception boundary, preflight\n -- included. The caller may own the surrounding transaction, and an error\n -- escaping this function would abort it — the #2026 contract. A probe that\n -- blocks on a table lock until statement_timeout is exactly that: it is not\n -- the insert, but it aborts the caller just the same.\n BEGIN\n IF to_regclass('_smrt_changes_pending') IS NULL THEN\n RETURN;\n END IF;\n\n -- Never sequence from inside a transaction that has already written. Doing\n -- so would hold freshly allocated sequences uncommitted for the rest of\n -- that transaction, which is precisely the wait edge #2649 removes — a\n -- reader calling getChangesSince() inside a long write transaction must\n -- not put it back. The staged rows keep for the next drain.\n IF pg_current_xact_id_if_assigned() IS NOT NULL THEN\n RETURN;\n END IF;\n\n -- Cheap common case: nothing staged, so an autocommit append pays one\n -- index probe rather than a drain.\n IF NOT EXISTS (SELECT 1 FROM _smrt_changes_pending LIMIT 1) THEN\n RETURN;\n END IF;\n\n -- Try, never wait: a drain that waited could become an edge in a cycle.\n IF NOT pg_try_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('change-feed-drain')\n ) THEN\n RETURN;\n END IF;\n\n SELECT COALESCE(MAX(changes.seq), 0) INTO v_base FROM _smrt_changes AS changes;\n\n RETURN QUERY\n WITH removed AS (\n DELETE FROM _smrt_changes_pending AS pending\n WHERE pending.pending_id IN (\n SELECT candidate.pending_id\n FROM _smrt_changes_pending AS candidate\n ORDER BY candidate.pending_id\n LIMIT GREATEST(COALESCE(p_limit, ${POSTGRES_CHANGE_FEED_DRAIN_BATCH}), 1)\n )\n RETURNING\n pending.pending_id,\n pending.table_name,\n pending.row_id,\n pending.operation,\n pending.tenant_id,\n pending.created_at\n ),\n numbered AS (\n SELECT\n removed.*,\n v_base + row_number() OVER (ORDER BY removed.pending_id) AS new_seq\n FROM removed\n ),\n inserted AS (\n INSERT INTO _smrt_changes (\n seq,\n table_name,\n row_id,\n operation,\n tenant_id,\n created_at\n )\n SELECT\n numbered.new_seq,\n numbered.table_name,\n numbered.row_id,\n numbered.operation,\n numbered.tenant_id,\n numbered.created_at\n FROM numbered\n RETURNING\n _smrt_changes.seq,\n _smrt_changes.table_name,\n _smrt_changes.row_id,\n _smrt_changes.operation,\n _smrt_changes.tenant_id\n )\n SELECT\n inserted.seq,\n inserted.table_name,\n inserted.row_id,\n inserted.operation,\n inserted.tenant_id,\n NULL::TEXT,\n NULL::TEXT\n FROM inserted\n ORDER BY inserted.seq;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT\n NULL::BIGINT,\n NULL::TEXT,\n NULL::TEXT,\n NULL::TEXT,\n NULL::TEXT,\n v_error_code,\n v_error_message;\n END;\nEND;\n$smrt_change_feed_drain$;\n`;\n\n/**\n * PostgreSQL-only change-feed append function.\n *\n * A PL/pgSQL block with an EXCEPTION handler runs its body in an internal\n * subtransaction. Returning SQLSTATE as data lets the caller log or retry a\n * failed best-effort append without leaving its surrounding transaction in\n * PostgreSQL's aborted (25P02) state. This statement contains dollar-quoted\n * semicolons, so callers must execute it whole rather than adding it to\n * {@link ALL_SYSTEM_TABLES}, whose portable DDL entries are semicolon-split.\n */\nexport const CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = `\nCREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(\n p_table_name TEXT,\n p_row_id TEXT,\n p_operation TEXT,\n p_tenant_id TEXT,\n p_created_at TIMESTAMPTZ\n)\nRETURNS TABLE(\n allocated_seq BIGINT,\n error_code TEXT,\n error_message TEXT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $smrt_change_feed$\n-- ${POSTGRES_CHANGE_FEED_HELPER_MARKER}\nDECLARE\n v_seq BIGINT;\n v_error_code TEXT;\n v_error_message TEXT;\n v_deferred BOOLEAN;\nBEGIN\n -- Has the caller already written in this transaction?\n --\n -- PostgreSQL assigns a transaction id at the first write, so a non-NULL\n -- pg_current_xact_id_if_assigned() means the caller may be holding row\n -- locks. Allocating the feed head here would then make a concurrent appender\n -- wait for this whole transaction to end — the wait edge that closes the\n -- #2649 cycle. An autocommit append reports NULL: its caller's row write\n -- committed as its own statement, so it holds no row locks and its inline\n -- allocation cannot be part of a cycle. (Requires PostgreSQL 13+.)\n --\n -- This is exactly the condition that makes a cycle possible, not an\n -- approximation of \"inside BEGIN\": a transaction that appends *before* its\n -- first write still allocates inline, but it holds no row locks at that\n -- moment, so nothing that waits on its feed row can also be waited on by it.\n -- Preflight inside its own exception boundary for the same reason the drain\n -- wraps its own: an error here would abort a caller-owned transaction\n -- (#2026), and a catalog probe is still a statement that can fail.\n BEGIN\n v_deferred := pg_current_xact_id_if_assigned() IS NOT NULL\n AND to_regclass('_smrt_changes_pending') IS NOT NULL;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::BIGINT, v_error_code, v_error_message;\n RETURN;\n END;\n\n IF v_deferred THEN\n BEGIN\n -- Staged, not sequenced: an identity value conflicts with nothing, so\n -- this insert never waits on another transaction. It stays fate-shared\n -- with the caller (rollback removes it) and is moved into\n -- _smrt_changes, in order and with a contiguous sequence, by the next\n -- drain after the caller commits.\n INSERT INTO _smrt_changes_pending (\n table_name,\n row_id,\n operation,\n tenant_id,\n created_at\n )\n VALUES (\n p_table_name,\n p_row_id,\n p_operation,\n p_tenant_id,\n p_created_at\n );\n\n -- NULL sequence with NULL error code is the staged marker.\n RETURN QUERY SELECT NULL::BIGINT, NULL::TEXT, NULL::TEXT;\n RETURN;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::BIGINT, v_error_code, v_error_message;\n RETURN;\n END;\n END IF;\n\n -- Autocommit append. The caller's own row write already committed as its own\n -- statement, so this transaction holds no user row locks and allocating the\n -- head here cannot join a lock cycle.\n --\n -- This deliberately does NOT drain: a drain performed here would sequence\n -- staged entries that the JavaScript caller never sees, so no live SSE\n -- signal would be published for them while this append's own signal carries\n -- a HIGHER sequence -- and an EventSource that stores that id as its\n -- Last-Event-ID resumes above them and never receives them. Draining is\n -- driven from JavaScript (drainChangeFeed) so every sequenced entry gets\n -- its signal.\n BEGIN\n INSERT INTO _smrt_changes (\n seq,\n table_name,\n row_id,\n operation,\n tenant_id,\n created_at\n )\n SELECT\n COALESCE(MAX(changes.seq), 0) + 1,\n p_table_name,\n p_row_id,\n p_operation,\n p_tenant_id,\n p_created_at\n FROM _smrt_changes AS changes\n RETURNING _smrt_changes.seq INTO v_seq;\n\n RETURN QUERY SELECT v_seq, NULL::TEXT, NULL::TEXT;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::BIGINT, v_error_code, v_error_message;\n END;\nEND;\n$smrt_change_feed$;\n`;\n\n/**\n * PostgreSQL batch append helper. It makes the staged/direct decision before\n * its own writes, then appends every element through that one path. This is\n * necessary because a direct first insert assigns the transaction id that\n * later entries must not mistake for a caller-owned write transaction.\n */\nexport const CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION = `\nCREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_NAME}(\n p_entries JSONB\n)\nRETURNS TABLE(\n entry_index INTEGER,\n allocated_seq BIGINT,\n error_code TEXT,\n error_message TEXT\n)\nLANGUAGE plpgsql\nSECURITY INVOKER\nAS $smrt_change_feed_batch$\n-- ${POSTGRES_CHANGE_FEED_HELPER_MARKER}\nDECLARE\n v_deferred BOOLEAN;\n v_error_code TEXT;\n v_error_message TEXT;\nBEGIN\n BEGIN\n v_deferred := pg_current_xact_id_if_assigned() IS NOT NULL\n AND to_regclass('_smrt_changes_pending') IS NOT NULL;\n\n IF v_deferred THEN\n RETURN QUERY\n WITH input AS (\n SELECT entries.ordinality::INTEGER - 1 AS entry_index, entries.value\n FROM jsonb_array_elements(COALESCE(p_entries, '[]'::jsonb))\n WITH ORDINALITY AS entries(value, ordinality)\n ), staged AS (\n INSERT INTO _smrt_changes_pending (\n table_name, row_id, operation, tenant_id, created_at\n )\n SELECT\n input.value->>'table',\n input.value->>'rowId',\n input.value->>'operation',\n input.value->>'tenantId',\n (input.value->>'timestamp')::TIMESTAMPTZ\n FROM input\n ORDER BY input.entry_index\n RETURNING 1\n )\n SELECT input.entry_index, NULL::BIGINT, NULL::TEXT, NULL::TEXT\n FROM input\n ORDER BY input.entry_index;\n RETURN;\n END IF;\n\n RETURN QUERY\n WITH input AS (\n SELECT entries.ordinality::INTEGER - 1 AS entry_index, entries.value\n FROM jsonb_array_elements(COALESCE(p_entries, '[]'::jsonb))\n WITH ORDINALITY AS entries(value, ordinality)\n ), numbered AS (\n SELECT\n input.*,\n COALESCE((SELECT MAX(seq) FROM _smrt_changes), 0)\n + row_number() OVER (ORDER BY input.entry_index) AS new_seq\n FROM input\n ), inserted AS (\n INSERT INTO _smrt_changes (\n seq, table_name, row_id, operation, tenant_id, created_at\n )\n SELECT\n numbered.new_seq,\n numbered.value->>'table',\n numbered.value->>'rowId',\n numbered.value->>'operation',\n numbered.value->>'tenantId',\n (numbered.value->>'timestamp')::TIMESTAMPTZ\n FROM numbered\n ORDER BY numbered.entry_index\n RETURNING seq\n )\n SELECT\n numbered.entry_index,\n numbered.new_seq,\n NULL::TEXT,\n NULL::TEXT\n FROM numbered\n ORDER BY numbered.entry_index;\n EXCEPTION WHEN query_canceled OR assert_failure OR OTHERS THEN\n GET STACKED DIAGNOSTICS\n v_error_code = RETURNED_SQLSTATE,\n v_error_message = MESSAGE_TEXT;\n RETURN QUERY SELECT NULL::INTEGER, NULL::BIGINT, v_error_code, v_error_message;\n END;\nEND;\n$smrt_change_feed_batch$;\n`;\n\nconst POSTGRES_CHANGE_FEED_PENDING_DDL =\n CREATE_POSTGRES_SMRT_CHANGES_PENDING_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0)\n .map(\n (statement) => `EXECUTE $smrt_change_feed_pending_ddl$\n${statement}\n$smrt_change_feed_pending_ddl$;`,\n )\n .join('\\n');\n\n/**\n * Serialize PostgreSQL helper replacement inside one server-side statement.\n *\n * The transaction-scoped advisory lock prevents concurrent bootstraps from\n * racing on PostgreSQL's `pg_proc` uniqueness constraint. The nested dollar\n * quote keeps the complete function DDL atomic from the client's perspective.\n */\nexport const REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = `\nDO $smrt_replace_change_feed$\nBEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n${POSTGRES_CHANGE_FEED_PENDING_DDL}\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_drain_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION.trim()}\n$smrt_change_feed_drain_ddl$;\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n EXECUTE $smrt_change_feed_batch_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION.trim()}\n$smrt_change_feed_batch_ddl$;\nEND;\n$smrt_replace_change_feed$;\n`;\n\n/**\n * Install the PostgreSQL helpers only when missing, serialized server-side.\n *\n * A client-side catalog probe remains the fast path for already-initialized\n * read handles. This guarded statement is the cold-path race boundary: both\n * the advisory lock and the post-lock catalog check run before function DDL.\n *\n * The staging table and the drain helper (#2649) are created unconditionally\n * (`IF NOT EXISTS` / `CREATE OR REPLACE`) because an install that already has\n * the append helper from an older SMRT may still be missing them; the append\n * helper itself is replaced when the staging table was absent, which is how an\n * upgraded database picks up the deferring version.\n */\nexport const ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = `\nDO $smrt_ensure_change_feed$\nDECLARE\n v_append_stale BOOLEAN;\nBEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n v_append_stale := to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') IS NULL\n OR NOT EXISTS (\n SELECT 1\n FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n ) OR NOT EXISTS (\n SELECT 1 FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n );\n${POSTGRES_CHANGE_FEED_PENDING_DDL}\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_drain_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION.trim()}\n$smrt_change_feed_drain_ddl$;\n IF v_append_stale THEN\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n EXECUTE $smrt_change_feed_batch_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION.trim()}\n$smrt_change_feed_batch_ddl$;\n END IF;\nEND;\n$smrt_ensure_change_feed$;\n`;\n\nconst POSTGRES_CHANGE_FEED_SCHEMA_DDL =\n CREATE_POSTGRES_SMRT_CHANGES_TABLE.split(';')\n .map((statement) => statement.trim())\n .filter((statement) => statement.length > 0)\n .map(\n (statement) => `EXECUTE $smrt_change_feed_schema_ddl$\n${statement}\n$smrt_change_feed_schema_ddl$;`,\n )\n .join('\\n');\n\n/**\n * Install the complete PostgreSQL change-feed schema under the bootstrap lock.\n *\n * The lock is deliberately acquired before table or index DDL. Framework\n * bootstrap uses the same ordering, so a raw-handle cold start cannot retain\n * catalog locks while waiting behind a framework bootstrap transaction.\n */\nexport const ENSURE_POSTGRES_CHANGE_FEED_SCHEMA = `\nDO $smrt_ensure_change_feed_schema$\nDECLARE\n v_append_stale BOOLEAN;\nBEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n v_append_stale := to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') IS NULL\n OR NOT EXISTS (\n SELECT 1\n FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n ) OR NOT EXISTS (\n SELECT 1 FROM pg_proc\n WHERE oid = to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION_IDENTITY}')\n AND prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'\n );\n${POSTGRES_CHANGE_FEED_SCHEMA_DDL}\n${POSTGRES_CHANGE_FEED_PENDING_DDL}\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_drain_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_DRAIN_FUNCTION.trim()}\n$smrt_change_feed_drain_ddl$;\n IF v_append_stale THEN\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n EXECUTE $smrt_change_feed_batch_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_BATCH_FUNCTION.trim()}\n$smrt_change_feed_batch_ddl$;\n END IF;\nEND;\n$smrt_ensure_change_feed_schema$;\n`;\n\n/**\n * Data backfill tracking\n *\n * Distinct from `_smrt_schema_migrations` — backfills are app-specific\n * data corrections (slug rewrites, model splits, lookup-table seeds) that\n * don't have schema diffs or rollback semantics. Apps register backfills\n * by name and the tracker handles idempotency.\n */\nexport const CREATE_SMRT_BACKFILLS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_backfills (\n name TEXT PRIMARY KEY,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n package_name TEXT\n);\n`;\n\n/** The insert-only claim table backing `runOnce()` (`../run-once.ts`). Browser-safe: retention imports it. */\nexport const RUN_ONCE_CLAIMS_TABLE = '_smrt_run_once_claims';\n\n/**\n * Idempotency claims for `runOnce()` (#3080).\n *\n * `claim_key` is the sha256 hex digest of the sorted-key-JSON encoding of\n * `(tenantId, actor, token, contentDigest)` — see `../run-once.ts` for the\n * derivation and why the token alone is not enough (it collapses distinct\n * submissions that reuse a form's token). It is the table's PRIMARY KEY, so\n * a second `INSERT` for the same key always raises a unique-constraint\n * violation instead of silently adopting the first caller's row: exactly the\n * \"insert-only against a UNIQUE column\" claim the issue asks for.\n *\n * Neither the raw token nor the raw submitted content is stored — only\n * digests — so this table cannot leak submitted business data. `status`\n * moves from `'in_progress'` to `'completed'` inside the SAME transaction\n * that runs the caller's work, so `result` (the caller's JSON-serialized,\n * ready-to-replay return value) and `completed_at` are only ever set once,\n * atomically with whatever else that transaction wrote. If the work throws,\n * `runOnce()` never marks the claim completed, and the transaction that\n * inserted it rolls back the insert along with it — a claim is never left\n * permanently `'in_progress'` by a failed attempt; a retry with the same key\n * can always run again.\n */\nexport const CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE = `\nCREATE TABLE IF NOT EXISTS _smrt_run_once_claims (\n claim_key TEXT PRIMARY KEY,\n tenant_id TEXT NOT NULL,\n actor TEXT NOT NULL,\n content_digest TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'in_progress',\n result TEXT,\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_smrt_run_once_claims_tenant\n ON _smrt_run_once_claims(tenant_id, created_at);\n\n-- Retention predicate for pruneRunOnceClaims() (#3080): completed claims are\n-- aged out on completed_at, scoped to status = 'completed' so an in_progress\n-- claim (completed_at IS NULL) is never matched by the sweep.\nCREATE INDEX IF NOT EXISTS idx_smrt_run_once_claims_status_completed\n ON _smrt_run_once_claims(status, completed_at);\n`;\n\n/**\n * All system table creation statements\n */\nexport const ALL_SYSTEM_TABLES = [\n CREATE_SMRT_CONTEXTS_TABLE,\n CREATE_SMRT_MIGRATIONS_TABLE,\n CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE,\n CREATE_SMRT_BACKFILLS_TABLE,\n CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE,\n CREATE_SMRT_EMBEDDINGS_TABLE,\n CREATE_SMRT_DISPATCH_TABLE,\n CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,\n CREATE_SMRT_AI_USAGE_TABLE,\n CREATE_SMRT_CHANGES_TABLE,\n];\n\n/**\n * Framework-owned tables that are backed by `@smrt()` models rather than by\n * {@link ALL_SYSTEM_TABLES}, and whose write volume disqualifies them from the\n * change feed.\n *\n * This is the jobs runner's own state: the claim loop and the forge projection\n * poll every second and rewrite these rows several times per job, so feeding\n * them into an append-only log whose pruning is not scheduled (issue #2375)\n * would dominate it with data no client syncs.\n *\n * The list is deliberately short and biased toward *observing*. A table wrongly\n * excluded is an invisible bug — exactly the one issue #2376 fixes — while a\n * table wrongly included costs a few extra feed rows. Everything else keeping\n * the `_smrt_` prefix (feature flags, prompt overrides, subscription plans,\n * field policies, agent and report schedules, report state) is observed.\n */\nexport const FRAMEWORK_OPERATIONAL_TABLES: readonly string[] = [\n '_smrt_jobs',\n '_smrt_job_events',\n '_smrt_workers',\n '_smrt_forge_deliveries',\n '_smrt_forge_projection_checkpoints',\n];\n\n/**\n * Framework tables that are backed by `@smrt()` models *and* reshaped by\n * `system/compatibility.ts`.\n *\n * They are dual-owned: `db:migrate` creates them from the jobs manifest, and\n * the compatibility pass adds columns/indexes that predate the manifest. On a\n * fresh install the manifest wins the race — bootstrap runs before the tables\n * exist — so their compatibility pass is deferred and re-checked until the\n * tables show up (issue #2376).\n */\nexport const DEFERRED_COMPATIBILITY_TABLES: readonly string[] = [\n '_smrt_jobs',\n '_smrt_job_events',\n];\n\n/**\n * System tables SMRT used to create on every database and no longer does.\n *\n * `_smrt_signals` never had a writer and `_smrt_registry` was only written by\n * an `ObjectRegistry.persistToDatabase()` helper that nothing ever called, so\n * both were empty on every deployment. New databases no longer get them.\n * Existing databases keep the empty tables until an operator drops them:\n *\n * ```sql\n * DROP TABLE IF EXISTS _smrt_signals;\n * DROP TABLE IF EXISTS _smrt_registry;\n * ```\n *\n * The framework deliberately does not issue those drops itself — dropping a\n * table is not reversible and a deployment may have repurposed the name.\n */\nexport const RETIRED_SYSTEM_TABLES: readonly string[] = [\n '_smrt_registry',\n '_smrt_signals',\n];\n\n/**\n * Materialize the portable system-table schema for a target engine.\n *\n * PostgreSQL must use TIMESTAMPTZ because every system Date is an instant.\n * PostgreSQL and DuckDB also materialize integer storage as BIGINT, matching\n * application-table DDL; SQLite keeps its variable-width INTEGER.\n */\nexport function getSystemTableDDL(engine: DatabaseEngine): string[] {\n return ALL_SYSTEM_TABLES.map((ddl) =>\n getSystemTableDDLForEngine(ddl, engine),\n );\n}\n\n/** Materialize one portable system-table statement for a target engine. */\nexport function getSystemTableDDLForEngine(\n ddl: string,\n engine: DatabaseEngine,\n): string {\n const integerDdl =\n engine === 'postgres' || engine === 'duckdb' || engine === 'json'\n ? ddl.replace(/\\bINTEGER\\b/g, 'BIGINT')\n : ddl;\n return engine === 'postgres'\n ? integerDdl.replace(/\\bTIMESTAMP\\b/g, 'TIMESTAMPTZ')\n : integerDdl;\n}\n\n/**\n * Current SMRT system schema version.\n *\n * `bootstrapSystemTables()` skips replaying system DDL once this exact version\n * is recorded in `_smrt_migrations`, so every change to {@link ALL_SYSTEM_TABLES}\n * must bump it — otherwise existing databases never see the change. New\n * *columns* additionally need an `addColumnIfMissing()` entry in\n * `system/compatibility.ts`, because `CREATE TABLE IF NOT EXISTS` is a no-op on\n * a table that already exists.\n *\n * 1.10.1 retires the never-written `_smrt_registry` / `_smrt_signals` tables\n * (see {@link RETIRED_SYSTEM_TABLES}) and adds the retention-predicate indexes\n * from #2375 (`_smrt_contexts.expires_at`, `_smrt_ai_usage(tenant_id,\n * created_at)`, `_smrt_dispatch(status, processed_at)` and\n * `(status, updated_at)`) in the same replay. (`1.10.0` briefly existed only\n * on two unreleased, unmerged branches with two different, incompatible DDL\n * sets — never published, never stamped in any real `_smrt_migrations` table\n * — so merging them reuses no version an existing install could have already\n * recorded; see the note on {@link SMRT_SCHEMA_DDL_CHECKSUMS}.)\n *\n * 1.11.0 adds `_smrt_run_once_claims` (#3080), the insert-only claim table\n * behind `runOnce()` (`../run-once.ts`).\n *\n * 1.11.1 adds `idx_smrt_run_once_claims_status_completed` (#3080), the\n * `(status, completed_at)` index backing `pruneRunOnceClaims()`'s retention\n * predicate (`system/retention.ts`).\n */\nexport const SMRT_SCHEMA_VERSION = '1.11.1';\n\n/**\n * Canonical form of the system DDL that {@link SMRT_SCHEMA_DDL_CHECKSUMS} covers.\n *\n * Whitespace is normalized so reformatting alone does not trip the guard while\n * any token change does.\n */\nexport function getSystemSchemaChecksumInput(): string {\n return ALL_SYSTEM_TABLES.join('\\n').replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * SHA-256 of {@link getSystemSchemaChecksumInput} for each system schema\n * version, oldest first.\n *\n * The discipline that keeps fresh installs and upgraded installs identical was\n * documented but unenforced (issue #2376): a contributor could edit the DDL and\n * ship it without bumping the version, and no existing database would ever\n * apply the change. `system-schema-evolution.test.ts` recomputes the current\n * hash and requires it to equal this map's entry for\n * {@link SMRT_SCHEMA_VERSION}.\n *\n * It is a *history*, not a single constant, so the guard cannot be silenced by\n * overwriting one value: every recorded hash must stay distinct, which means a\n * changed DDL has nowhere to be recorded except under a new version key. Adding\n * that key is the bump. This is also why merging two branches that each\n * independently bumped to the same version string (both `1.10.0`, covering two\n * different DDL sets — #2376's table retirement and #2375's retention indexes)\n * must land under a *new* key rather than picking a \"final\" checksum for the\n * shared number: nothing published or otherwise reachable ever stamped\n * `1.10.0`, so recording the merged DDL as `1.10.1` costs nothing and removes\n * any doubt about a version string meaning two different things to two\n * installs.\n *\n * Entries before 1.10.1 are not recorded — the guard starts here.\n */\nexport const SMRT_SCHEMA_DDL_CHECKSUMS: Readonly<Record<string, string>> =\n Object.freeze({\n '1.10.1':\n 'f796ee3b3f7ab8b9dc659ecaa68884ec01277408c3dd6edea540853fce369c16',\n '1.11.0':\n 'c51351a76c96beb64fa7e5922f78d776367af882ed1ff37229f9e9ffacee8372',\n '1.11.1':\n '98112937a3bbb3883c3db1fd5e836751f91a479c1c5da64d4410263a6581ef1f',\n });\n"],"mappings":";;;;;AAaA,IAAa,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0C1C,IAAa,+BAA+B;;;;;;;;;;;;;AAc5C,IAAa,sCAAsC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnD,IAAa,+BAA+B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiC5C,IAAa,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD1C,IAAa,2CAA2C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCxD,IAAa,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0D1C,IAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2CzC,IAAa,6CAA6C;;;;;;;;;;;;;;AAe1D,IAAa,qCAAqC;;AAGlD,IAAa,qCACX,0BAA0B,QAAQ,kBAAkB,aAAa;;AAGnE,IAAa,4CAA4C;;AAGzD,IAAa,kDACX;;AAGF,IAAa,uDAAuD,GAAG,0CAA0C;;AAGjH,IAAa,gDAAgD,GAAG,0CAA0C;;AAG1G,IAAa,sDAAsD,GAAG,gDAAgD;;;;;;;;;;AAWtH,IAAa,qCAAqC;;AAGlD,IAAa,2CAA2C;;AAGxD,IAAa,+CAA+C,GAAG,yCAAyC;;AAGxG,IAAa,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;AA0BhD,IAAa,6CAA6C;6BAC7B,yCAAyC;4BAC1C,iCAAiC;;;;;;;;;;;;;;KAcxD,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAgDG,iCAAiC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6E5E,IAAa,8CAA8C;6BAC9B,0CAA0C;;;;;;;;;;;;;;;KAelE,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoHxC,IAAa,oDAAoD;6BACpC,gDAAgD;;;;;;;;;;;;KAYxE,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+ExC,IAAM,mCACJ,2CAA2C,MAAM,GAAG,CAAC,CAClD,KAAK,cAAc,UAAU,KAAK,CAAC,CAAC,CACpC,QAAQ,cAAc,UAAU,SAAS,CAAC,CAAC,CAC3C,KACE,cAAc;EACnB,UAAU;gCAER,CAAC,CACA,KAAK,IAAI;;;;;;;;AASd,IAAa,+CAA+C;;;;;;;EAO1D,iCAAiC;4BACP,qDAAqD;;EAE/E,2CAA2C,KAAK,EAAE;;;EAGlD,4CAA4C,KAAK,EAAE;;;EAGnD,kDAAkD,KAAK,EAAE;;;;;;;;;;;;;;;;;;AAmB3D,IAAa,8CAA8C;;;;;;;;;mCASxB,mCAAmC;;;;qCAIjC,8CAA8C;4BACvD,mCAAmC;;;qCAG1B,oDAAoD;4BAC7D,mCAAmC;;EAE7D,iCAAiC;4BACP,qDAAqD;;EAE/E,2CAA2C,KAAK,EAAE;;;;EAIlD,4CAA4C,KAAK,EAAE;;;EAGnD,kDAAkD,KAAK,EAAE;;;;;;;;;;;;;AAyB3D,IAAa,qCAAqC;;;;;;;;;mCASf,mCAAmC;;;;qCAIjC,8CAA8C;4BACvD,mCAAmC;;;qCAG1B,oDAAoD;4BAC7D,mCAAmC;;EAnC7D,mCAAmC,MAAM,GAAG,CAAC,CAC1C,KAAK,cAAc,UAAU,KAAK,CAAC,CAAC,CACpC,QAAQ,cAAc,UAAU,SAAS,CAAC,CAAC,CAC3C,KACE,cAAc;EACnB,UAAU;+BAER,CAAC,CACA,KAAK,IA6BR,EAAgC;EAChC,iCAAiC;4BACP,qDAAqD;;EAE/E,2CAA2C,KAAK,EAAE;;;;EAIlD,4CAA4C,KAAK,EAAE;;;EAGnD,kDAAkD,KAAK,EAAE;;;;;;;;;;;;;;AAe3D,IAAa,8BAA8B;;;;;;;;;AAU3C,IAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;AAwBrC,IAAa,oCAAoC;;;;;;;;;;;;;;;;;;;;;;;;AAyBjD,IAAa,oBAAoB;CAC/B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;;;;AAkBA,IAAa,+BAAkD;CAC7D;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;;;;AAiCA,IAAa,wBAA2C,CACtD,kBACA,eACF;;;;;;;;AASA,SAAgB,kBAAkB,QAAkC;CAClE,OAAO,kBAAkB,KAAK,QAC5B,2BAA2B,KAAK,MAAM,CACxC;AACF;;AAGA,SAAgB,2BACd,KACA,QACQ;CACR,MAAM,aACJ,WAAW,cAAc,WAAW,YAAY,WAAW,SACvD,IAAI,QAAQ,gBAAgB,QAAQ,IACpC;CACN,OAAO,WAAW,aACd,WAAW,QAAQ,kBAAkB,aAAa,IAClD;AACN;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,IAAa,sBAAsB;AAsCjC,OAAO,OAAO;CACZ,UACE;CACF,UACE;CACF,UACE;AACJ,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"changes-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/changes-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAWjE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,GAC5B,MAAM,GAAG,IAAI,CASf;AAED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAKT;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CA2BT"}
1
+ {"version":3,"file":"changes-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/changes-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH,OAAO,KAAK,EAEV,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAWjE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,mBAAmB,GAC5B,MAAM,GAAG,IAAI,CASf;AAED,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAKT;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CA2BT"}
@@ -18,8 +18,14 @@ import { join } from "node:path";
18
18
  * - **Tenant scoping**: when the project has tenant-scoped objects, the
19
19
  * route establishes tenant context from `locals` exactly like generated
20
20
  * collection routes, then reads through
21
- * `getTenantScopedChangesSince()` — a tenant only ever sees its own
22
- * changes plus global rows.
21
+ * `getAuthorizedTenantScopedChangesSince()` — a tenant only ever sees its
22
+ * own changes plus global rows.
23
+ * - **Table/row authorization** (#3020): the same call additionally applies
24
+ * the consumer-supplied `authorizeChangeFeed`/`isChangeFeedEntryVisible`
25
+ * hooks (`@happyvertical/smrt-core`'s `change-feed-authz.ts`), if
26
+ * registered — a table-level and row-level seam beyond tenant scope (e.g. a
27
+ * station principal reading only its own rows of a table the office can
28
+ * read in full). Unregistered, behavior is unchanged from pre-#3020.
23
29
  * - **Database resolution**: the route anchors on the project's first
24
30
  * generated collection (alphabetical) via the consumer's existing
25
31
  * `getCollection()` helper, inheriting its configuration, request-scoped
@@ -92,9 +98,17 @@ function generateChangesRouteTemplate(anchorClassName, tenantScoped, configImpor
92
98
  // HTTP 200 — protocol state, not an error) means the cursor cannot be
93
99
  // served incrementally (pruned or foreign) and the client must re-fetch
94
100
  // in full before resuming polling from resyncCursor.
101
+ //
102
+ // Table/row authorization (#3020): beyond tenant scope, the read additionally
103
+ // applies the consumer-supplied \`authorizeChangeFeed\`/
104
+ // \`isChangeFeedEntryVisible\` hooks, if registered (see
105
+ // \`@happyvertical/smrt-core\`'s \`change-feed-authz.ts\`). A denied table or row
106
+ // is never returned — filtering happens before the response is built, not on
107
+ // the client — and the cursor still advances past a denied entry so polling
108
+ // never stalls or re-requests it.
95
109
 
96
110
  import { error, json } from '@sveltejs/kit';
97
- import { getTenantScopedChangesSince } from '@happyvertical/smrt-core';
111
+ import { getAuthorizedTenantScopedChangesSince } from '@happyvertical/smrt-core';
98
112
  import { getCollection } from '${configImport}';
99
113
  import type { RequestHandler } from './$types';
100
114
 
@@ -132,7 +146,7 @@ function establishTenantContext(locals: unknown): void {
132
146
  }
133
147
  }
134
148
  ` : ""}
135
- export const GET: RequestHandler = async ({ locals, url }) => {
149
+ export const GET: RequestHandler = async ({ locals, url, request }) => {
136
150
  requireRouteAuth(locals);${tenantScoped ? "\n establishTenantContext(locals);" : ""}
137
151
 
138
152
  const since = Number(url.searchParams.get('since') ?? '0');
@@ -160,10 +174,12 @@ export const GET: RequestHandler = async ({ locals, url }) => {
160
174
  // The feed lives in the project's database; anchor on the
161
175
  // ${anchorClassName} collection to reuse its configured connection.
162
176
  const collection = await getCollection('${anchorClassName}');
163
- const page = await getTenantScopedChangesSince(collection.db, {
177
+ const page = await getAuthorizedTenantScopedChangesSince(collection.db, {
164
178
  since,
165
179
  tables,
166
180
  limit,
181
+ locals,
182
+ request,
167
183
  });
168
184
  return json(page);
169
185
  };
@@ -1 +1 @@
1
- {"version":3,"file":"changes-route.js","names":[],"sources":["../../src/vite-plugin/changes-route.ts"],"sourcesContent":["/**\n * SvelteKit `_changes` route generation for the change feed (issue #1758).\n *\n * Emits `{routesDir}/_changes/+server.ts` — an auth-guarded, tenant-scoped\n * GET endpoint over the `_smrt_changes` log, part of the client/mobile sync\n * contract (PRD #1755). Kept in its own module so `sveltekit-generator.ts`\n * only carries a one-line registration.\n *\n * Design notes:\n * - **Fail-closed auth** (#1540 posture): the handler requires an\n * authenticated principal on `locals`. The feed spans every table, so\n * per-model `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scoping**: when the project has tenant-scoped objects, the\n * route establishes tenant context from `locals` exactly like generated\n * collection routes, then reads through\n * `getTenantScopedChangesSince()` — a tenant only ever sees its own\n * changes plus global rows.\n * - **Database resolution**: the route anchors on the project's first\n * generated collection (alphabetical) via the consumer's existing\n * `getCollection()` helper, inheriting its configuration, request-scoped\n * database support and system-table bootstrap. Multi-database projects\n * (per-object `db` overrides) see the anchor collection's feed.\n * - Cleanup rides the existing generated-route sweep: the emitted file\n * starts with {@link AUTO_GENERATED_ROUTE_HEADER}.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport { resolveSvelteKitConfigImport } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\n/**\n * Mirrors `isCollectionClass` in `sveltekit-generator.ts` (module-private\n * there): collection classes share route paths with their item class and\n * never anchor routes themselves.\n */\nfunction isCollectionDefinition(objectDef: SmartObjectDefinition): boolean {\n return objectDef.extends === 'SmrtCollection' || !!objectDef.extendsTypeArg;\n}\n\n/**\n * Pick the class the route resolves its database through: the first\n * non-collection object by sorted manifest key, for determinism across\n * builds.\n *\n * Returns the manifest **registry key** verbatim (which may be\n * package-qualified, e.g. `@happyvertical/smrt-ledgers:Account`) — the emitted\n * route passes it straight to `getCollection()`, exactly as the generated CRUD\n * routes do. Collapsing it to a simple class name would resolve ambiguously\n * when two loaded packages declare the same simple name (mirrors the #1778\n * verbatim-key fix and the sync-apply route's `registryKey`).\n */\nexport function resolveAnchorClassName(\n manifest: SmartObjectManifest,\n): string | null {\n const sortedNames = Object.keys(manifest.objects).sort();\n for (const name of sortedNames) {\n const def = manifest.objects[name];\n if (def && !isCollectionDefinition(def)) {\n return name;\n }\n }\n return null;\n}\n\nexport function manifestHasTenantScopedObject(\n manifest: SmartObjectManifest,\n): boolean {\n return Object.values(manifest.objects).some(\n (def) =>\n !isCollectionDefinition(def) && !!def.decoratorConfig?.tenantScoped,\n );\n}\n\n/**\n * Generate the `_changes/+server.ts` route. Returns true when a route was\n * written. Disabled with `sveltekit: { changesRoute: { enabled: false } }`;\n * skipped (with a log line) when the manifest has no objects to anchor the\n * database on.\n */\nexport function generateChangesRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n): boolean {\n if (options.changesRoute?.enabled === false) {\n return false;\n }\n\n const anchorClassName = resolveAnchorClassName(manifest);\n if (!anchorClassName) {\n console.log(\n '[smrt] Skipping _changes route - no SMRT objects to anchor the database on',\n );\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, '_changes');\n const content = generateChangesRouteTemplate(\n anchorClassName,\n manifestHasTenantScopedObject(manifest),\n resolveSvelteKitConfigImport(projectRoot, routeDir, options),\n );\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n const filePath = join(routeDir, '+server.ts');\n writeFileSync(filePath, content, 'utf-8');\n console.log(`[smrt] Generated: ${filePath}`);\n return true;\n}\n\nfunction generateChangesRouteTemplate(\n anchorClassName: string,\n tenantScoped: boolean,\n configImport: string,\n): string {\n const tenantHelper = tenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n const tenantCall = tenantScoped ? '\\n establishTenantContext(locals);' : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// GET /_changes — cursor read over the _smrt_changes change feed (#1758).\n// Part of the client/mobile sync contract: poll with the returned cursor to\n// observe every committed framework save/delete (deletes are tombstones)\n// exactly once. Query params: since (cursor, default 0), tables\n// (comma-separated), limit. A response with resyncRequired: true (still\n// HTTP 200 — protocol state, not an error) means the cursor cannot be\n// served incrementally (pruned or foreign) and the client must re-fetch\n// in full before resuming polling from resyncCursor.\n\nimport { error, json } from '@sveltejs/kit';\nimport { getTenantScopedChangesSince } from '@happyvertical/smrt-core';\nimport { getCollection } from '${configImport}';\nimport type { RequestHandler } from './$types';\n\n// Fail-closed authorization (#1540): the change feed spans every table, so\n// it is never public — an authenticated principal on \\`locals\\` is required.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nfunction requireRouteAuth(locals: unknown): void {\n if (!hasAuthenticatedPrincipal(locals)) {\n throw error(401, 'Authentication required');\n }\n}\n${tenantHelper}\nexport const GET: RequestHandler = async ({ locals, url }) => {\n requireRouteAuth(locals);${tenantCall}\n\n const since = Number(url.searchParams.get('since') ?? '0');\n if (!Number.isFinite(since) || since < 0) {\n throw error(400, \"'since' must be a non-negative number\");\n }\n\n let limit: number | undefined;\n const limitParam = url.searchParams.get('limit');\n if (limitParam !== null) {\n limit = Number(limitParam);\n if (!Number.isFinite(limit) || limit < 1) {\n throw error(400, \"'limit' must be a positive number\");\n }\n }\n\n const tablesParam = url.searchParams.get('tables');\n const tables = tablesParam\n ? tablesParam\n .split(',')\n .map((table) => table.trim())\n .filter(Boolean)\n : undefined;\n\n // The feed lives in the project's database; anchor on the\n // ${anchorClassName} collection to reuse its configured connection.\n const collection = await getCollection('${anchorClassName}');\n const page = await getTenantScopedChangesSince(collection.db, {\n since,\n tables,\n limit,\n });\n return json(page);\n};\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAS,uBAAuB,WAA2C;CACzE,OAAO,UAAU,YAAY,oBAAoB,CAAC,CAAC,UAAU;AAC/D;;;;;;;;;;;;;AAcA,SAAgB,uBACd,UACe;CACf,MAAM,cAAc,OAAO,KAAK,SAAS,OAAO,CAAC,CAAC,KAAK;CACvD,KAAK,MAAM,QAAQ,aAAa;EAC9B,MAAM,MAAM,SAAS,QAAQ;EAC7B,IAAI,OAAO,CAAC,uBAAuB,GAAG,GACpC,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAgB,8BACd,UACS;CACT,OAAO,OAAO,OAAO,SAAS,OAAO,CAAC,CAAC,MACpC,QACC,CAAC,uBAAuB,GAAG,KAAK,CAAC,CAAC,IAAI,iBAAiB,YAC3D;AACF;;;;;;;AAQA,SAAgB,qBACd,aACA,UACA,SACS;CACT,IAAI,QAAQ,cAAc,YAAY,OACpC,OAAO;CAGT,MAAM,kBAAkB,uBAAuB,QAAQ;CACvD,IAAI,CAAC,iBAAiB;EACpB,QAAQ,IACN,4EACF;EACA,OAAO;CACT;CAEA,MAAM,WAAW,KAAK,aAAa,QAAQ,WAAW,UAAU;CAChE,MAAM,UAAU,6BACd,iBACA,8BAA8B,QAAQ,GACtC,6BAA6B,aAAa,UAAU,OAAO,CAC7D;CAEA,IAAI,CAAC,WAAW,QAAQ,GACtB,UAAU,UAAU,EAAE,WAAW,KAAK,CAAC;CAEzC,MAAM,WAAW,KAAK,UAAU,YAAY;CAC5C,cAAc,UAAU,SAAS,OAAO;CACxC,QAAQ,IAAI,qBAAqB,UAAU;CAC3C,OAAO;AACT;AAEA,SAAS,6BACP,iBACA,cACA,cACQ;CAoBR,OAAO,GAAG,4BAA4B;;;;;;;;;;;;;;iCAcP,aAAa;;;;;;;;;;;;;;;;;;;;;;EAjCvB,eACjB;;;;;;;;;;;;;;IAeA,GAuCS;;6BAtCM,eAAe,wCAAwC,GAwCpC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBjC,gBAAgB;4CACqB,gBAAgB;;;;;;;;;AAS5D"}
1
+ {"version":3,"file":"changes-route.js","names":[],"sources":["../../src/vite-plugin/changes-route.ts"],"sourcesContent":["/**\n * SvelteKit `_changes` route generation for the change feed (issue #1758).\n *\n * Emits `{routesDir}/_changes/+server.ts` — an auth-guarded, tenant-scoped\n * GET endpoint over the `_smrt_changes` log, part of the client/mobile sync\n * contract (PRD #1755). Kept in its own module so `sveltekit-generator.ts`\n * only carries a one-line registration.\n *\n * Design notes:\n * - **Fail-closed auth** (#1540 posture): the handler requires an\n * authenticated principal on `locals`. The feed spans every table, so\n * per-model `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scoping**: when the project has tenant-scoped objects, the\n * route establishes tenant context from `locals` exactly like generated\n * collection routes, then reads through\n * `getAuthorizedTenantScopedChangesSince()` — a tenant only ever sees its\n * own changes plus global rows.\n * - **Table/row authorization** (#3020): the same call additionally applies\n * the consumer-supplied `authorizeChangeFeed`/`isChangeFeedEntryVisible`\n * hooks (`@happyvertical/smrt-core`'s `change-feed-authz.ts`), if\n * registered — a table-level and row-level seam beyond tenant scope (e.g. a\n * station principal reading only its own rows of a table the office can\n * read in full). Unregistered, behavior is unchanged from pre-#3020.\n * - **Database resolution**: the route anchors on the project's first\n * generated collection (alphabetical) via the consumer's existing\n * `getCollection()` helper, inheriting its configuration, request-scoped\n * database support and system-table bootstrap. Multi-database projects\n * (per-object `db` overrides) see the anchor collection's feed.\n * - Cleanup rides the existing generated-route sweep: the emitted file\n * starts with {@link AUTO_GENERATED_ROUTE_HEADER}.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type {\n SmartObjectDefinition,\n SmartObjectManifest,\n} from '../scanner/types';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport { resolveSvelteKitConfigImport } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\n/**\n * Mirrors `isCollectionClass` in `sveltekit-generator.ts` (module-private\n * there): collection classes share route paths with their item class and\n * never anchor routes themselves.\n */\nfunction isCollectionDefinition(objectDef: SmartObjectDefinition): boolean {\n return objectDef.extends === 'SmrtCollection' || !!objectDef.extendsTypeArg;\n}\n\n/**\n * Pick the class the route resolves its database through: the first\n * non-collection object by sorted manifest key, for determinism across\n * builds.\n *\n * Returns the manifest **registry key** verbatim (which may be\n * package-qualified, e.g. `@happyvertical/smrt-ledgers:Account`) — the emitted\n * route passes it straight to `getCollection()`, exactly as the generated CRUD\n * routes do. Collapsing it to a simple class name would resolve ambiguously\n * when two loaded packages declare the same simple name (mirrors the #1778\n * verbatim-key fix and the sync-apply route's `registryKey`).\n */\nexport function resolveAnchorClassName(\n manifest: SmartObjectManifest,\n): string | null {\n const sortedNames = Object.keys(manifest.objects).sort();\n for (const name of sortedNames) {\n const def = manifest.objects[name];\n if (def && !isCollectionDefinition(def)) {\n return name;\n }\n }\n return null;\n}\n\nexport function manifestHasTenantScopedObject(\n manifest: SmartObjectManifest,\n): boolean {\n return Object.values(manifest.objects).some(\n (def) =>\n !isCollectionDefinition(def) && !!def.decoratorConfig?.tenantScoped,\n );\n}\n\n/**\n * Generate the `_changes/+server.ts` route. Returns true when a route was\n * written. Disabled with `sveltekit: { changesRoute: { enabled: false } }`;\n * skipped (with a log line) when the manifest has no objects to anchor the\n * database on.\n */\nexport function generateChangesRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n): boolean {\n if (options.changesRoute?.enabled === false) {\n return false;\n }\n\n const anchorClassName = resolveAnchorClassName(manifest);\n if (!anchorClassName) {\n console.log(\n '[smrt] Skipping _changes route - no SMRT objects to anchor the database on',\n );\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, '_changes');\n const content = generateChangesRouteTemplate(\n anchorClassName,\n manifestHasTenantScopedObject(manifest),\n resolveSvelteKitConfigImport(projectRoot, routeDir, options),\n );\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n const filePath = join(routeDir, '+server.ts');\n writeFileSync(filePath, content, 'utf-8');\n console.log(`[smrt] Generated: ${filePath}`);\n return true;\n}\n\nfunction generateChangesRouteTemplate(\n anchorClassName: string,\n tenantScoped: boolean,\n configImport: string,\n): string {\n const tenantHelper = tenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n const tenantCall = tenantScoped ? '\\n establishTenantContext(locals);' : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// GET /_changes — cursor read over the _smrt_changes change feed (#1758).\n// Part of the client/mobile sync contract: poll with the returned cursor to\n// observe every committed framework save/delete (deletes are tombstones)\n// exactly once. Query params: since (cursor, default 0), tables\n// (comma-separated), limit. A response with resyncRequired: true (still\n// HTTP 200 — protocol state, not an error) means the cursor cannot be\n// served incrementally (pruned or foreign) and the client must re-fetch\n// in full before resuming polling from resyncCursor.\n//\n// Table/row authorization (#3020): beyond tenant scope, the read additionally\n// applies the consumer-supplied \\`authorizeChangeFeed\\`/\n// \\`isChangeFeedEntryVisible\\` hooks, if registered (see\n// \\`@happyvertical/smrt-core\\`'s \\`change-feed-authz.ts\\`). A denied table or row\n// is never returned — filtering happens before the response is built, not on\n// the client — and the cursor still advances past a denied entry so polling\n// never stalls or re-requests it.\n\nimport { error, json } from '@sveltejs/kit';\nimport { getAuthorizedTenantScopedChangesSince } from '@happyvertical/smrt-core';\nimport { getCollection } from '${configImport}';\nimport type { RequestHandler } from './$types';\n\n// Fail-closed authorization (#1540): the change feed spans every table, so\n// it is never public — an authenticated principal on \\`locals\\` is required.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nfunction requireRouteAuth(locals: unknown): void {\n if (!hasAuthenticatedPrincipal(locals)) {\n throw error(401, 'Authentication required');\n }\n}\n${tenantHelper}\nexport const GET: RequestHandler = async ({ locals, url, request }) => {\n requireRouteAuth(locals);${tenantCall}\n\n const since = Number(url.searchParams.get('since') ?? '0');\n if (!Number.isFinite(since) || since < 0) {\n throw error(400, \"'since' must be a non-negative number\");\n }\n\n let limit: number | undefined;\n const limitParam = url.searchParams.get('limit');\n if (limitParam !== null) {\n limit = Number(limitParam);\n if (!Number.isFinite(limit) || limit < 1) {\n throw error(400, \"'limit' must be a positive number\");\n }\n }\n\n const tablesParam = url.searchParams.get('tables');\n const tables = tablesParam\n ? tablesParam\n .split(',')\n .map((table) => table.trim())\n .filter(Boolean)\n : undefined;\n\n // The feed lives in the project's database; anchor on the\n // ${anchorClassName} collection to reuse its configured connection.\n const collection = await getCollection('${anchorClassName}');\n const page = await getAuthorizedTenantScopedChangesSince(collection.db, {\n since,\n tables,\n limit,\n locals,\n request,\n });\n return json(page);\n};\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CA,SAAS,uBAAuB,WAA2C;CACzE,OAAO,UAAU,YAAY,oBAAoB,CAAC,CAAC,UAAU;AAC/D;;;;;;;;;;;;;AAcA,SAAgB,uBACd,UACe;CACf,MAAM,cAAc,OAAO,KAAK,SAAS,OAAO,CAAC,CAAC,KAAK;CACvD,KAAK,MAAM,QAAQ,aAAa;EAC9B,MAAM,MAAM,SAAS,QAAQ;EAC7B,IAAI,OAAO,CAAC,uBAAuB,GAAG,GACpC,OAAO;CAEX;CACA,OAAO;AACT;AAEA,SAAgB,8BACd,UACS;CACT,OAAO,OAAO,OAAO,SAAS,OAAO,CAAC,CAAC,MACpC,QACC,CAAC,uBAAuB,GAAG,KAAK,CAAC,CAAC,IAAI,iBAAiB,YAC3D;AACF;;;;;;;AAQA,SAAgB,qBACd,aACA,UACA,SACS;CACT,IAAI,QAAQ,cAAc,YAAY,OACpC,OAAO;CAGT,MAAM,kBAAkB,uBAAuB,QAAQ;CACvD,IAAI,CAAC,iBAAiB;EACpB,QAAQ,IACN,4EACF;EACA,OAAO;CACT;CAEA,MAAM,WAAW,KAAK,aAAa,QAAQ,WAAW,UAAU;CAChE,MAAM,UAAU,6BACd,iBACA,8BAA8B,QAAQ,GACtC,6BAA6B,aAAa,UAAU,OAAO,CAC7D;CAEA,IAAI,CAAC,WAAW,QAAQ,GACtB,UAAU,UAAU,EAAE,WAAW,KAAK,CAAC;CAEzC,MAAM,WAAW,KAAK,UAAU,YAAY;CAC5C,cAAc,UAAU,SAAS,OAAO;CACxC,QAAQ,IAAI,qBAAqB,UAAU;CAC3C,OAAO;AACT;AAEA,SAAS,6BACP,iBACA,cACA,cACQ;CAoBR,OAAO,GAAG,4BAA4B;;;;;;;;;;;;;;;;;;;;;;iCAsBP,aAAa;;;;;;;;;;;;;;;;;;;;;;EAzCvB,eACjB;;;;;;;;;;;;;;IAeA,GA+CS;;6BA9CM,eAAe,wCAAwC,GAgDpC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBjC,gBAAgB;4CACqB,gBAAgB;;;;;;;;;;;AAW5D"}
@@ -1 +1 @@
1
- {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAO5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,EACzB,eAAe,CAAC,EAAE,MAAM,GACvB,OAAO,CA+BT"}
1
+ {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAO5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,EACzB,eAAe,CAAC,EAAE,MAAM,GACvB,OAAO,CA+BT"}
@@ -27,6 +27,13 @@ import { join } from "node:path";
27
27
  * then captures the resolved scope ONCE at connection open and passes it to
28
28
  * the stream (delivery filters against that fixed value — it cannot
29
29
  * re-resolve per signal outside the request's ALS context).
30
+ * - **Table/row authorization** (#3020): `locals` is forwarded to
31
+ * `buildChangeEventStream`, which — if the consumer registered
32
+ * `authorizeChangeFeed`/`isChangeFeedEntryVisible`
33
+ * (`@happyvertical/smrt-core`'s `change-feed-authz.ts`) — resolves the
34
+ * allowed tables ONCE at connection open (same reasoning as tenant scope)
35
+ * and applies both hooks to catch-up replay AND every live signal before it
36
+ * is enqueued. Unregistered, behavior is unchanged from pre-#3020.
30
37
  * - **Database resolution**: anchors on the project's first generated
31
38
  * collection (alphabetical) via `getCollection()`, exactly as `_changes`.
32
39
  * - Cleanup rides the generated-route sweep: the file starts with
@@ -232,6 +239,8 @@ ${corsBlock.capacityReturn}
232
239
  tenantScope,
233
240
  manifestHash: MANIFEST_HASH,
234
241
  releaseSubscriberSlot,
242
+ locals,
243
+ request,
235
244
  }),
236
245
  {
237
246
  status: 200,
@@ -1 +1 @@
1
- {"version":3,"file":"events-route.js","names":[],"sources":["../../src/vite-plugin/events-route.ts"],"sourcesContent":["/**\n * SvelteKit `_events` route generation — live change signals (issue #1763,\n * SERVER half; parent PRD #1755).\n *\n * Emits `{routesDir}/_events/+server.ts` — an auth-guarded, tenant-scoped\n * Server-Sent-Events endpoint that streams coarse change signals (no row\n * payloads). It is the push companion to the generated `_changes` route\n * (#1758). Kept in its own module so `sveltekit-generator.ts` only carries a\n * one-line registration.\n *\n * The stream lifecycle (subscribe, catch-up, heartbeat, teardown) lives in\n * core's `buildChangeEventStream`, imported by the generated file — so the\n * emitted route stays THIN and the stream logic is written and tested once.\n *\n * Design notes (mirror `changes-route.ts`):\n * - **Fail-closed auth** (#1540 posture): the handler requires an authenticated\n * principal on `locals`. The signal stream spans every table, so per-model\n * `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scoping**: when the project has tenant-scoped objects, the route\n * establishes tenant context from `locals` exactly like the `_changes` route,\n * then captures the resolved scope ONCE at connection open and passes it to\n * the stream (delivery filters against that fixed value — it cannot\n * re-resolve per signal outside the request's ALS context).\n * - **Database resolution**: anchors on the project's first generated\n * collection (alphabetical) via `getCollection()`, exactly as `_changes`.\n * - Cleanup rides the generated-route sweep: the file starts with\n * {@link AUTO_GENERATED_ROUTE_HEADER}.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { SmartObjectManifest } from '../scanner/types';\nimport {\n manifestHasTenantScopedObject,\n resolveAnchorClassName,\n} from './changes-route.js';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport { resolveSvelteKitConfigImport } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\n/**\n * Generate the `_events/+server.ts` route. Returns true when a route was\n * written. Disabled with `sveltekit: { eventsRoute: { enabled: false } }`;\n * skipped (with a log line) when the manifest has no objects to anchor on.\n */\nexport function generateEventsRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n webManifestHash?: string,\n): boolean {\n if (options.eventsRoute?.enabled === false) {\n return false;\n }\n\n const anchorClassName = resolveAnchorClassName(manifest);\n if (!anchorClassName) {\n console.log(\n '[smrt] Skipping _events route - no SMRT objects to anchor the database on',\n );\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, '_events');\n const content = generateEventsRouteTemplate(\n anchorClassName,\n manifestHasTenantScopedObject(manifest),\n webManifestHash,\n options.eventsRoute?.maxSubscribers,\n normalizeAllowedOrigins(options.eventsRoute?.allowedOrigins),\n options.eventsRoute?.allowCredentials === true,\n resolveSvelteKitConfigImport(projectRoot, routeDir, options),\n );\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n const filePath = join(routeDir, '+server.ts');\n writeFileSync(filePath, content, 'utf-8');\n console.log(`[smrt] Generated: ${filePath}`);\n return true;\n}\n\n/**\n * Trim, de-duplicate, and drop empty entries from a configured origin\n * allowlist. An `undefined`/empty result means \"same-origin only\" — the\n * generated route emits no CORS surface at all (fail-closed default, #1861).\n */\nfunction normalizeAllowedOrigins(\n origins: string[] | undefined,\n): string[] | undefined {\n if (!Array.isArray(origins)) return undefined;\n const cleaned = [\n ...new Set(\n origins\n .filter((o): o is string => typeof o === 'string')\n .map((o) => o.trim())\n .filter((o) => o.length > 0),\n ),\n ];\n return cleaned.length > 0 ? cleaned : undefined;\n}\n\n/**\n * Build the CORS fragments injected into the generated `_events` route (#1861)\n * when an origin allowlist is configured. Mirrors the runtime REST generator's\n * posture: the request `Origin` is echoed only when it is on the allowlist\n * (never `*`), `Vary: Origin` is always set on a matched response, and\n * `Access-Control-Allow-Credentials: true` is added only when credentials are\n * opted in — so cookies flow to an allow-listed cross-origin `EventSource`\n * while an unmatched origin gets no CORS surface (fail-closed).\n */\nfunction buildEventsCorsBlock(\n allowedOrigins: string[],\n allowCredentials: boolean,\n): {\n helpers: string;\n streamHeadersSpread: string;\n capacityReturn: string;\n optionsHandler: string;\n} {\n const originsLiteral = JSON.stringify(allowedOrigins);\n const credentialsLine = allowCredentials\n ? \"\\n headers['Access-Control-Allow-Credentials'] = 'true';\"\n : '';\n const helpers = `\n// Credentialed cross-origin CORS (#1861). Opt-in via\n// \\`sveltekit.eventsRoute.allowedOrigins\\` (+ \\`allowCredentials\\`); an unmatched or\n// absent Origin gets no CORS surface, so this route stays same-origin only by\n// default. The Origin is echoed only when allow-listed (never \\`*\\`), which is\n// what makes credentialed CORS safe. CORS never authorizes — the fail-closed\n// \\`requireRouteAuth\\` guard below is unchanged; CORS only lets an allow-listed\n// browser's cookies reach it.\nconst CORS_ALLOWED_ORIGINS = new Set(${originsLiteral});\n\nfunction eventsCorsHeaders(request: Request): Record<string, string> {\n const origin = request.headers.get('origin');\n if (!origin || !CORS_ALLOWED_ORIGINS.has(origin)) return {};\n const headers: Record<string, string> = {\n 'Access-Control-Allow-Origin': origin,\n Vary: 'Origin',\n };${credentialsLine}\n return headers;\n}\n\nfunction applyEventsCors(response: Response, request: Request): Response {\n for (const [key, value] of Object.entries(eventsCorsHeaders(request))) {\n response.headers.set(key, value);\n }\n return response;\n}\n`;\n const streamHeadersSpread = '...eventsCorsHeaders(request),\\n ';\n const capacityReturn =\n ' return applyEventsCors(eventStreamCapacityExceededResponse(), request);';\n const optionsHandler = `\n// Preflight for a credentialed cross-origin subscription. \\`EventSource\\` GETs\n// are \"simple\" requests (no preflight), but a fetch-based SSE client may\n// preflight — answer it for allow-listed origins only.\nexport const OPTIONS: RequestHandler = async ({ request }) => {\n const headers = eventsCorsHeaders(request);\n if (!('Access-Control-Allow-Origin' in headers)) {\n return new Response(null, { status: 403 });\n }\n return new Response(null, {\n status: 204,\n headers: {\n ...headers,\n 'Access-Control-Allow-Methods': 'GET,OPTIONS',\n 'Access-Control-Allow-Headers': 'Authorization,Last-Event-ID,Content-Type',\n 'Access-Control-Max-Age': '86400',\n },\n });\n};\n`;\n return { helpers, streamHeadersSpread, capacityReturn, optionsHandler };\n}\n\nfunction generateEventsRouteTemplate(\n anchorClassName: string,\n tenantScoped: boolean,\n manifestHash: string | undefined,\n maxSubscribers: number | undefined,\n allowedOrigins: string[] | undefined,\n allowCredentials: boolean,\n configImport: string,\n): string {\n const configuredMaxSubscribers =\n maxSubscribers !== undefined &&\n Number.isFinite(maxSubscribers) &&\n maxSubscribers >= 0\n ? Math.floor(maxSubscribers)\n : undefined;\n const maxSubscribersArg =\n configuredMaxSubscribers === undefined\n ? ''\n : `, ${JSON.stringify(configuredMaxSubscribers)}`;\n const manifestHashLiteral =\n manifestHash === undefined ? 'undefined' : JSON.stringify(manifestHash);\n // CORS is emitted only when an explicit allowlist is configured (#1861).\n // Without one the generated route carries no CORS surface — same-origin only,\n // byte-for-byte the pre-#1861 output.\n const corsEnabled = allowedOrigins !== undefined;\n const corsBlock = corsEnabled\n ? buildEventsCorsBlock(allowedOrigins, allowCredentials)\n : {\n helpers: '',\n streamHeadersSpread: '',\n capacityReturn: ' return eventStreamCapacityExceededResponse();',\n optionsHandler: '',\n };\n const tenantHelper = tenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n const tenantCall = tenantScoped ? '\\n establishTenantContext(locals);' : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// GET /_events — Server-Sent-Events stream of coarse change signals (#1763).\n// Part of the client/mobile sync contract: each event names {table, operation,\n// rowId, tenantId} (NEVER a row payload — authorization stays on the read path)\n// and carries a seq cursor in the SSE id: field. A reconnecting EventSource\n// resends Last-Event-ID; the route replays missed changes from it, then streams\n// live. Same-origin only unless an origin allowlist is configured (#1861).\n\nimport { error } from '@sveltejs/kit';\nimport {\n buildChangeEventStream,\n eventStreamCapacityExceededResponse,\n resolveDispatchTenantScope,\n tryReserveChangeEventSubscriberSlot,\n} from '@happyvertical/smrt-core';\nimport { getCollection } from '${configImport}';\nimport type { RequestHandler } from './$types';\n\nconst MANIFEST_HASH = ${manifestHashLiteral};\n${corsBlock.helpers}\n// Fail-closed authorization (#1540): the signal stream spans every table, so\n// it is never public — an authenticated principal on \\`locals\\` is required.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nfunction requireRouteAuth(locals: unknown): void {\n if (!hasAuthenticatedPrincipal(locals)) {\n throw error(401, 'Authentication required');\n }\n}\n\n// Last-Event-ID (auto-reconnect) takes precedence over ?since=; default is\n// live-forward only (no catch-up replay).\nfunction parseCursor(request: Request, url: URL): number | null {\n const lastEventId = request.headers.get('Last-Event-ID');\n if (lastEventId !== null && lastEventId.trim() !== '') {\n const n = Number(lastEventId);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n const since = url.searchParams.get('since');\n if (since !== null && since.trim() !== '') {\n const n = Number(since);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n return null;\n}\n${tenantHelper}\nexport const GET: RequestHandler = async ({ locals, url, request }) => {\n requireRouteAuth(locals);${tenantCall}\n\n const cursor = parseCursor(request, url);\n // Capture the tenant scope ONCE at connection open — signal delivery runs\n // outside this request's tenant context and must filter against a fixed\n // value, not a per-signal re-resolution. resolveDispatchTenantScope() reads\n // the context established above (fail-closed: tenancy on + no tenant → global\n // rows only; tenancy off → unenforced, all rows).\n const tenantScope = resolveDispatchTenantScope();\n\n // The feed lives in the project's database; anchor on the\n // ${anchorClassName} collection to reuse its configured connection.\n const collection = await getCollection('${anchorClassName}');\n const releaseSubscriberSlot = tryReserveChangeEventSubscriberSlot(collection.db${maxSubscribersArg});\n if (!releaseSubscriberSlot) {\n${corsBlock.capacityReturn}\n }\n return new Response(\n buildChangeEventStream(collection.db, {\n cursor,\n tenantScope,\n manifestHash: MANIFEST_HASH,\n releaseSubscriberSlot,\n }),\n {\n status: 200,\n headers: {\n ${corsBlock.streamHeadersSpread}'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n },\n );\n};\n${corsBlock.optionsHandler}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6CA,SAAgB,oBACd,aACA,UACA,SACA,iBACS;CACT,IAAI,QAAQ,aAAa,YAAY,OACnC,OAAO;CAGT,MAAM,kBAAkB,uBAAuB,QAAQ;CACvD,IAAI,CAAC,iBAAiB;EACpB,QAAQ,IACN,2EACF;EACA,OAAO;CACT;CAEA,MAAM,WAAW,KAAK,aAAa,QAAQ,WAAW,SAAS;CAC/D,MAAM,UAAU,4BACd,iBACA,8BAA8B,QAAQ,GACtC,iBACA,QAAQ,aAAa,gBACrB,wBAAwB,QAAQ,aAAa,cAAc,GAC3D,QAAQ,aAAa,qBAAqB,MAC1C,6BAA6B,aAAa,UAAU,OAAO,CAC7D;CAEA,IAAI,CAAC,WAAW,QAAQ,GACtB,UAAU,UAAU,EAAE,WAAW,KAAK,CAAC;CAEzC,MAAM,WAAW,KAAK,UAAU,YAAY;CAC5C,cAAc,UAAU,SAAS,OAAO;CACxC,QAAQ,IAAI,qBAAqB,UAAU;CAC3C,OAAO;AACT;;;;;;AAOA,SAAS,wBACP,SACsB;CACtB,IAAI,CAAC,MAAM,QAAQ,OAAO,GAAG,OAAO,KAAA;CACpC,MAAM,UAAU,CACd,GAAG,IAAI,IACL,QACG,QAAQ,MAAmB,OAAO,MAAM,QAAQ,CAAC,CACjD,KAAK,MAAM,EAAE,KAAK,CAAC,CAAC,CACpB,QAAQ,MAAM,EAAE,SAAS,CAAC,CAC/B,CACF;CACA,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;;;;;;;;;;AAWA,SAAS,qBACP,gBACA,kBAMA;CAuDA,OAAO;EAAE,SAAA;;;;;;;;uCAtDc,KAAK,UAAU,cAYD,EAAe;;;;;;;;MAX5B,mBACpB,8DACA,GAiBgB;;;;;;;;;;;EAkCF,qBAAA;EAAqB,gBAAA;EAAgB,gBAAA;;;;;;;;;;;;;;;;;;;;CAAe;AACxE;AAEA,SAAS,4BACP,iBACA,cACA,cACA,gBACA,gBACA,kBACA,cACQ;CACR,MAAM,2BACJ,mBAAmB,KAAA,KACnB,OAAO,SAAS,cAAc,KAC9B,kBAAkB,IACd,KAAK,MAAM,cAAc,IACzB,KAAA;CACN,MAAM,oBACJ,6BAA6B,KAAA,IACzB,KACA,KAAK,KAAK,UAAU,wBAAwB;CAClD,MAAM,sBACJ,iBAAiB,KAAA,IAAY,cAAc,KAAK,UAAU,YAAY;CAKxE,MAAM,YADc,mBAAmB,KAAA,IAEnC,qBAAqB,gBAAgB,gBAAgB,IACrD;EACE,SAAS;EACT,qBAAqB;EACrB,gBAAgB;EAChB,gBAAgB;CAClB;CACJ,MAAM,eAAe,eACjB;;;;;;;;;;;;;;IAeA;CACJ,MAAM,aAAa,eAAe,wCAAwC;CAE1E,OAAO,GAAG,4BAA4B;;;;;;;;;;;;;;;;;iCAiBP,aAAa;;;wBAGtB,oBAAoB;EAC1C,UAAU,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoClB,aAAa;;6BAEc,WAAW;;;;;;;;;;;OAWjC,gBAAgB;4CACqB,gBAAgB;mFACuB,kBAAkB;;EAEnG,UAAU,eAAe;;;;;;;;;;;;UAYjB,UAAU,oBAAoB;;;;;;;;EAQtC,UAAU;AACZ"}
1
+ {"version":3,"file":"events-route.js","names":[],"sources":["../../src/vite-plugin/events-route.ts"],"sourcesContent":["/**\n * SvelteKit `_events` route generation — live change signals (issue #1763,\n * SERVER half; parent PRD #1755).\n *\n * Emits `{routesDir}/_events/+server.ts` — an auth-guarded, tenant-scoped\n * Server-Sent-Events endpoint that streams coarse change signals (no row\n * payloads). It is the push companion to the generated `_changes` route\n * (#1758). Kept in its own module so `sveltekit-generator.ts` only carries a\n * one-line registration.\n *\n * The stream lifecycle (subscribe, catch-up, heartbeat, teardown) lives in\n * core's `buildChangeEventStream`, imported by the generated file — so the\n * emitted route stays THIN and the stream logic is written and tested once.\n *\n * Design notes (mirror `changes-route.ts`):\n * - **Fail-closed auth** (#1540 posture): the handler requires an authenticated\n * principal on `locals`. The signal stream spans every table, so per-model\n * `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scoping**: when the project has tenant-scoped objects, the route\n * establishes tenant context from `locals` exactly like the `_changes` route,\n * then captures the resolved scope ONCE at connection open and passes it to\n * the stream (delivery filters against that fixed value — it cannot\n * re-resolve per signal outside the request's ALS context).\n * - **Table/row authorization** (#3020): `locals` is forwarded to\n * `buildChangeEventStream`, which — if the consumer registered\n * `authorizeChangeFeed`/`isChangeFeedEntryVisible`\n * (`@happyvertical/smrt-core`'s `change-feed-authz.ts`) — resolves the\n * allowed tables ONCE at connection open (same reasoning as tenant scope)\n * and applies both hooks to catch-up replay AND every live signal before it\n * is enqueued. Unregistered, behavior is unchanged from pre-#3020.\n * - **Database resolution**: anchors on the project's first generated\n * collection (alphabetical) via `getCollection()`, exactly as `_changes`.\n * - Cleanup rides the generated-route sweep: the file starts with\n * {@link AUTO_GENERATED_ROUTE_HEADER}.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { SmartObjectManifest } from '../scanner/types';\nimport {\n manifestHasTenantScopedObject,\n resolveAnchorClassName,\n} from './changes-route.js';\nimport { AUTO_GENERATED_ROUTE_HEADER } from './route-header.js';\nimport { resolveSvelteKitConfigImport } from './sveltekit-config-import.js';\nimport type { SvelteKitOptions } from './sveltekit-generator.js';\n\n/**\n * Generate the `_events/+server.ts` route. Returns true when a route was\n * written. Disabled with `sveltekit: { eventsRoute: { enabled: false } }`;\n * skipped (with a log line) when the manifest has no objects to anchor on.\n */\nexport function generateEventsRoute(\n projectRoot: string,\n manifest: SmartObjectManifest,\n options: SvelteKitOptions,\n webManifestHash?: string,\n): boolean {\n if (options.eventsRoute?.enabled === false) {\n return false;\n }\n\n const anchorClassName = resolveAnchorClassName(manifest);\n if (!anchorClassName) {\n console.log(\n '[smrt] Skipping _events route - no SMRT objects to anchor the database on',\n );\n return false;\n }\n\n const routeDir = join(projectRoot, options.routesDir, '_events');\n const content = generateEventsRouteTemplate(\n anchorClassName,\n manifestHasTenantScopedObject(manifest),\n webManifestHash,\n options.eventsRoute?.maxSubscribers,\n normalizeAllowedOrigins(options.eventsRoute?.allowedOrigins),\n options.eventsRoute?.allowCredentials === true,\n resolveSvelteKitConfigImport(projectRoot, routeDir, options),\n );\n\n if (!existsSync(routeDir)) {\n mkdirSync(routeDir, { recursive: true });\n }\n const filePath = join(routeDir, '+server.ts');\n writeFileSync(filePath, content, 'utf-8');\n console.log(`[smrt] Generated: ${filePath}`);\n return true;\n}\n\n/**\n * Trim, de-duplicate, and drop empty entries from a configured origin\n * allowlist. An `undefined`/empty result means \"same-origin only\" — the\n * generated route emits no CORS surface at all (fail-closed default, #1861).\n */\nfunction normalizeAllowedOrigins(\n origins: string[] | undefined,\n): string[] | undefined {\n if (!Array.isArray(origins)) return undefined;\n const cleaned = [\n ...new Set(\n origins\n .filter((o): o is string => typeof o === 'string')\n .map((o) => o.trim())\n .filter((o) => o.length > 0),\n ),\n ];\n return cleaned.length > 0 ? cleaned : undefined;\n}\n\n/**\n * Build the CORS fragments injected into the generated `_events` route (#1861)\n * when an origin allowlist is configured. Mirrors the runtime REST generator's\n * posture: the request `Origin` is echoed only when it is on the allowlist\n * (never `*`), `Vary: Origin` is always set on a matched response, and\n * `Access-Control-Allow-Credentials: true` is added only when credentials are\n * opted in — so cookies flow to an allow-listed cross-origin `EventSource`\n * while an unmatched origin gets no CORS surface (fail-closed).\n */\nfunction buildEventsCorsBlock(\n allowedOrigins: string[],\n allowCredentials: boolean,\n): {\n helpers: string;\n streamHeadersSpread: string;\n capacityReturn: string;\n optionsHandler: string;\n} {\n const originsLiteral = JSON.stringify(allowedOrigins);\n const credentialsLine = allowCredentials\n ? \"\\n headers['Access-Control-Allow-Credentials'] = 'true';\"\n : '';\n const helpers = `\n// Credentialed cross-origin CORS (#1861). Opt-in via\n// \\`sveltekit.eventsRoute.allowedOrigins\\` (+ \\`allowCredentials\\`); an unmatched or\n// absent Origin gets no CORS surface, so this route stays same-origin only by\n// default. The Origin is echoed only when allow-listed (never \\`*\\`), which is\n// what makes credentialed CORS safe. CORS never authorizes — the fail-closed\n// \\`requireRouteAuth\\` guard below is unchanged; CORS only lets an allow-listed\n// browser's cookies reach it.\nconst CORS_ALLOWED_ORIGINS = new Set(${originsLiteral});\n\nfunction eventsCorsHeaders(request: Request): Record<string, string> {\n const origin = request.headers.get('origin');\n if (!origin || !CORS_ALLOWED_ORIGINS.has(origin)) return {};\n const headers: Record<string, string> = {\n 'Access-Control-Allow-Origin': origin,\n Vary: 'Origin',\n };${credentialsLine}\n return headers;\n}\n\nfunction applyEventsCors(response: Response, request: Request): Response {\n for (const [key, value] of Object.entries(eventsCorsHeaders(request))) {\n response.headers.set(key, value);\n }\n return response;\n}\n`;\n const streamHeadersSpread = '...eventsCorsHeaders(request),\\n ';\n const capacityReturn =\n ' return applyEventsCors(eventStreamCapacityExceededResponse(), request);';\n const optionsHandler = `\n// Preflight for a credentialed cross-origin subscription. \\`EventSource\\` GETs\n// are \"simple\" requests (no preflight), but a fetch-based SSE client may\n// preflight — answer it for allow-listed origins only.\nexport const OPTIONS: RequestHandler = async ({ request }) => {\n const headers = eventsCorsHeaders(request);\n if (!('Access-Control-Allow-Origin' in headers)) {\n return new Response(null, { status: 403 });\n }\n return new Response(null, {\n status: 204,\n headers: {\n ...headers,\n 'Access-Control-Allow-Methods': 'GET,OPTIONS',\n 'Access-Control-Allow-Headers': 'Authorization,Last-Event-ID,Content-Type',\n 'Access-Control-Max-Age': '86400',\n },\n });\n};\n`;\n return { helpers, streamHeadersSpread, capacityReturn, optionsHandler };\n}\n\nfunction generateEventsRouteTemplate(\n anchorClassName: string,\n tenantScoped: boolean,\n manifestHash: string | undefined,\n maxSubscribers: number | undefined,\n allowedOrigins: string[] | undefined,\n allowCredentials: boolean,\n configImport: string,\n): string {\n const configuredMaxSubscribers =\n maxSubscribers !== undefined &&\n Number.isFinite(maxSubscribers) &&\n maxSubscribers >= 0\n ? Math.floor(maxSubscribers)\n : undefined;\n const maxSubscribersArg =\n configuredMaxSubscribers === undefined\n ? ''\n : `, ${JSON.stringify(configuredMaxSubscribers)}`;\n const manifestHashLiteral =\n manifestHash === undefined ? 'undefined' : JSON.stringify(manifestHash);\n // CORS is emitted only when an explicit allowlist is configured (#1861).\n // Without one the generated route carries no CORS surface — same-origin only,\n // byte-for-byte the pre-#1861 output.\n const corsEnabled = allowedOrigins !== undefined;\n const corsBlock = corsEnabled\n ? buildEventsCorsBlock(allowedOrigins, allowCredentials)\n : {\n helpers: '',\n streamHeadersSpread: '',\n capacityReturn: ' return eventStreamCapacityExceededResponse();',\n optionsHandler: '',\n };\n const tenantHelper = tenantScoped\n ? `\nimport { enterTenantContext, hasTenantContext } from '@happyvertical/smrt-tenancy';\n\nfunction establishTenantContext(locals: unknown): void {\n if (hasTenantContext()) return;\n if (!locals || typeof locals !== 'object') return;\n const l = locals as Record<string, unknown>;\n const user = l.user as Record<string, unknown> | undefined;\n const session = l.session as Record<string, unknown> | undefined;\n const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId;\n if (typeof tenantId === 'string' && tenantId) {\n enterTenantContext({ tenantId });\n }\n}\n`\n : '';\n const tenantCall = tenantScoped ? '\\n establishTenantContext(locals);' : '';\n\n return `${AUTO_GENERATED_ROUTE_HEADER}\n// DO NOT EDIT - changes will be overwritten\n//\n// GET /_events — Server-Sent-Events stream of coarse change signals (#1763).\n// Part of the client/mobile sync contract: each event names {table, operation,\n// rowId, tenantId} (NEVER a row payload — authorization stays on the read path)\n// and carries a seq cursor in the SSE id: field. A reconnecting EventSource\n// resends Last-Event-ID; the route replays missed changes from it, then streams\n// live. Same-origin only unless an origin allowlist is configured (#1861).\n\nimport { error } from '@sveltejs/kit';\nimport {\n buildChangeEventStream,\n eventStreamCapacityExceededResponse,\n resolveDispatchTenantScope,\n tryReserveChangeEventSubscriberSlot,\n} from '@happyvertical/smrt-core';\nimport { getCollection } from '${configImport}';\nimport type { RequestHandler } from './$types';\n\nconst MANIFEST_HASH = ${manifestHashLiteral};\n${corsBlock.helpers}\n// Fail-closed authorization (#1540): the signal stream spans every table, so\n// it is never public — an authenticated principal on \\`locals\\` is required.\nfunction hasAuthenticatedPrincipal(locals: unknown): boolean {\n if (!locals || typeof locals !== 'object') return false;\n const l = locals as Record<string, unknown>;\n const isResolvedPrincipal = (v: unknown) =>\n typeof v === 'object' && v !== null;\n return (\n isResolvedPrincipal(l.user) ||\n isResolvedPrincipal(l.session) ||\n l.smrtAuth === true\n );\n}\n\nfunction requireRouteAuth(locals: unknown): void {\n if (!hasAuthenticatedPrincipal(locals)) {\n throw error(401, 'Authentication required');\n }\n}\n\n// Last-Event-ID (auto-reconnect) takes precedence over ?since=; default is\n// live-forward only (no catch-up replay).\nfunction parseCursor(request: Request, url: URL): number | null {\n const lastEventId = request.headers.get('Last-Event-ID');\n if (lastEventId !== null && lastEventId.trim() !== '') {\n const n = Number(lastEventId);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n const since = url.searchParams.get('since');\n if (since !== null && since.trim() !== '') {\n const n = Number(since);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n return null;\n}\n${tenantHelper}\nexport const GET: RequestHandler = async ({ locals, url, request }) => {\n requireRouteAuth(locals);${tenantCall}\n\n const cursor = parseCursor(request, url);\n // Capture the tenant scope ONCE at connection open — signal delivery runs\n // outside this request's tenant context and must filter against a fixed\n // value, not a per-signal re-resolution. resolveDispatchTenantScope() reads\n // the context established above (fail-closed: tenancy on + no tenant → global\n // rows only; tenancy off → unenforced, all rows).\n const tenantScope = resolveDispatchTenantScope();\n\n // The feed lives in the project's database; anchor on the\n // ${anchorClassName} collection to reuse its configured connection.\n const collection = await getCollection('${anchorClassName}');\n const releaseSubscriberSlot = tryReserveChangeEventSubscriberSlot(collection.db${maxSubscribersArg});\n if (!releaseSubscriberSlot) {\n${corsBlock.capacityReturn}\n }\n return new Response(\n buildChangeEventStream(collection.db, {\n cursor,\n tenantScope,\n manifestHash: MANIFEST_HASH,\n releaseSubscriberSlot,\n locals,\n request,\n }),\n {\n status: 200,\n headers: {\n ${corsBlock.streamHeadersSpread}'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n },\n );\n};\n${corsBlock.optionsHandler}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDA,SAAgB,oBACd,aACA,UACA,SACA,iBACS;CACT,IAAI,QAAQ,aAAa,YAAY,OACnC,OAAO;CAGT,MAAM,kBAAkB,uBAAuB,QAAQ;CACvD,IAAI,CAAC,iBAAiB;EACpB,QAAQ,IACN,2EACF;EACA,OAAO;CACT;CAEA,MAAM,WAAW,KAAK,aAAa,QAAQ,WAAW,SAAS;CAC/D,MAAM,UAAU,4BACd,iBACA,8BAA8B,QAAQ,GACtC,iBACA,QAAQ,aAAa,gBACrB,wBAAwB,QAAQ,aAAa,cAAc,GAC3D,QAAQ,aAAa,qBAAqB,MAC1C,6BAA6B,aAAa,UAAU,OAAO,CAC7D;CAEA,IAAI,CAAC,WAAW,QAAQ,GACtB,UAAU,UAAU,EAAE,WAAW,KAAK,CAAC;CAEzC,MAAM,WAAW,KAAK,UAAU,YAAY;CAC5C,cAAc,UAAU,SAAS,OAAO;CACxC,QAAQ,IAAI,qBAAqB,UAAU;CAC3C,OAAO;AACT;;;;;;AAOA,SAAS,wBACP,SACsB;CACtB,IAAI,CAAC,MAAM,QAAQ,OAAO,GAAG,OAAO,KAAA;CACpC,MAAM,UAAU,CACd,GAAG,IAAI,IACL,QACG,QAAQ,MAAmB,OAAO,MAAM,QAAQ,CAAC,CACjD,KAAK,MAAM,EAAE,KAAK,CAAC,CAAC,CACpB,QAAQ,MAAM,EAAE,SAAS,CAAC,CAC/B,CACF;CACA,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;;;;;;;;;;AAWA,SAAS,qBACP,gBACA,kBAMA;CAuDA,OAAO;EAAE,SAAA;;;;;;;;uCAtDc,KAAK,UAAU,cAYD,EAAe;;;;;;;;MAX5B,mBACpB,8DACA,GAiBgB;;;;;;;;;;;EAkCF,qBAAA;EAAqB,gBAAA;EAAgB,gBAAA;;;;;;;;;;;;;;;;;;;;CAAe;AACxE;AAEA,SAAS,4BACP,iBACA,cACA,cACA,gBACA,gBACA,kBACA,cACQ;CACR,MAAM,2BACJ,mBAAmB,KAAA,KACnB,OAAO,SAAS,cAAc,KAC9B,kBAAkB,IACd,KAAK,MAAM,cAAc,IACzB,KAAA;CACN,MAAM,oBACJ,6BAA6B,KAAA,IACzB,KACA,KAAK,KAAK,UAAU,wBAAwB;CAClD,MAAM,sBACJ,iBAAiB,KAAA,IAAY,cAAc,KAAK,UAAU,YAAY;CAKxE,MAAM,YADc,mBAAmB,KAAA,IAEnC,qBAAqB,gBAAgB,gBAAgB,IACrD;EACE,SAAS;EACT,qBAAqB;EACrB,gBAAgB;EAChB,gBAAgB;CAClB;CACJ,MAAM,eAAe,eACjB;;;;;;;;;;;;;;IAeA;CACJ,MAAM,aAAa,eAAe,wCAAwC;CAE1E,OAAO,GAAG,4BAA4B;;;;;;;;;;;;;;;;;iCAiBP,aAAa;;;wBAGtB,oBAAoB;EAC1C,UAAU,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoClB,aAAa;;6BAEc,WAAW;;;;;;;;;;;OAWjC,gBAAgB;4CACqB,gBAAgB;mFACuB,kBAAkB;;EAEnG,UAAU,eAAe;;;;;;;;;;;;;;UAcjB,UAAU,oBAAoB;;;;;;;;EAQtC,UAAU;AACZ"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/index.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@ import { renderApiClientCrudType, renderApiClientCustomMethodParameters, selectA
11
11
  import { GENERATED_CLIENT_BASE_METHODS, GENERATED_CLIENT_CRUD_METHODS, generateClientModule } from "./generated-client.js";
12
12
  import { importBuildAwareModule } from "./import-build-aware.js";
13
13
  import { assertSvelteKitRouteCoordinationComplete, contributeSvelteKitRoutes, expectedSvelteKitRouteOwners, markSvelteKitRouteParticipant } from "./sveltekit-route-coordinator.js";
14
+ import { buildWorkerRegistration } from "./worker-registration.js";
14
15
  import { existsSync, readFileSync } from "node:fs";
15
16
  import { dirname, join, resolve } from "node:path";
16
17
  import { fileURLToPath } from "node:url";
@@ -325,6 +326,7 @@ function smrtPlugin(options = {}) {
325
326
  let generatedRoutesDuringConfig = false;
326
327
  let routeLifecycleConfig;
327
328
  let routeExpectedOwners = ["producer"];
329
+ let workerRegistrationBuilt = false;
328
330
  async function generateConfiguredSvelteKitRoutes(rootDir, currentManifest) {
329
331
  const routeOptions = {
330
332
  enabled: svelteKit.enabled,
@@ -655,6 +657,18 @@ function smrtPlugin(options = {}) {
655
657
  }
656
658
  },
657
659
  async closeBundle() {
660
+ const serverBuild = this.environment?.config?.consumer === "server" || !!config?.build?.ssr;
661
+ if (svelteKit.enabled && config?.command === "build" && !config.build?.lib && serverBuild && !workerRegistrationBuilt) {
662
+ workerRegistrationBuilt = true;
663
+ const registrationFile = join(projectRoot, svelteKit.configPath || "src/lib/server", "smrt-register.ts");
664
+ if (!existsSync(registrationFile)) throw new Error(`[smrt] Cannot compile worker registration: ${registrationFile} was not generated.`);
665
+ const output = await buildWorkerRegistration({
666
+ projectRoot,
667
+ registrationFile,
668
+ resolvedConfig: config
669
+ });
670
+ console.log(`[smrt] Compiled worker registration to ${output}`);
671
+ }
658
672
  if (!manifest || !config?.build?.lib || generationSnapshot) return;
659
673
  try {
660
674
  const { writeFileSync, mkdirSync } = await import("node:fs");