@happyvertical/smrt-core 0.40.15 → 0.40.17
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.
- package/AGENTS.md +5 -0
- package/dist/browser.js +2 -2
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +21 -4
- package/dist/change-feed.js.map +1 -1
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +5 -3
- package/dist/class.js.map +1 -1
- package/dist/collection.d.ts +10 -0
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +16 -2
- package/dist/collection.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +11 -6
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/manifest/static-manifest.js +2 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
- package/dist/manifest/test-manifest-stub.js +384 -2
- package/dist/manifest/test-manifest-stub.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/migrations/backfill-tracker.d.ts +1 -0
- package/dist/migrations/backfill-tracker.d.ts.map +1 -1
- package/dist/migrations/backfill-tracker.js +9 -3
- package/dist/migrations/backfill-tracker.js.map +1 -1
- package/dist/migrations/differ.d.ts +18 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +28 -5
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts +8 -0
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +5 -1
- package/dist/migrations/generator.js.map +1 -1
- package/dist/migrations/orchestrate.d.ts +10 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +8 -2
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +4 -2
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +13 -6
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +54 -15
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/registry.js.map +1 -1
- package/dist/schema/code-generator.js +123 -0
- package/dist/schema/code-generator.js.map +1 -0
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +2 -2
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +2 -3
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/index.js +26 -2
- package/dist/schema/ddl/index.js.map +1 -1
- package/dist/schema/ddl/materialize-manifest.d.ts +36 -0
- package/dist/schema/ddl/materialize-manifest.d.ts.map +1 -0
- package/dist/schema/ddl/materialize-manifest.js +361 -0
- package/dist/schema/ddl/materialize-manifest.js.map +1 -0
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +1 -1
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/index.js +12 -0
- package/dist/schema/override-system.js +205 -0
- package/dist/schema/override-system.js.map +1 -0
- package/dist/schema/schema-aggregator.d.ts.map +1 -1
- package/dist/schema/schema-aggregator.js +261 -0
- package/dist/schema/schema-aggregator.js.map +1 -0
- package/dist/schema/schema-manager.js +11 -1
- package/dist/schema/schema-manager.js.map +1 -1
- package/dist/schema/utils.d.ts +1 -0
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +2 -1
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +13 -0
- package/dist/smrt-knowledge.json +7 -6
- package/dist/system/compatibility.d.ts +25 -0
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +61 -1
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/index.js +2 -2
- package/dist/system/schema.d.ts +17 -9
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +25 -11
- package/dist/system/schema.js.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +5 -2
- package/dist/testing/database.js.map +1 -1
- package/package.json +10 -5
package/dist/smrt-knowledge.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-20T11:31:31.536Z",
|
|
4
4
|
"packageName": "@happyvertical/smrt-core",
|
|
5
|
-
"packageVersion": "0.40.
|
|
5
|
+
"packageVersion": "0.40.17",
|
|
6
6
|
"sourceManifestPath": "dist/manifest.json",
|
|
7
7
|
"agentDocPath": "AGENTS.md",
|
|
8
8
|
"sourceHashes": {
|
|
9
|
-
"manifest": "
|
|
10
|
-
"packageJson": "
|
|
11
|
-
"agents": "
|
|
9
|
+
"manifest": "de0914ca628f09c8fc39f09052b067671cad630252bdce59667a4c033fae184f",
|
|
10
|
+
"packageJson": "034b517ed2bd5e87256306835ba94831f398afd2821099ceafeded9cbcd32527",
|
|
11
|
+
"agents": "51e716c002ba5e152e5b8f63c8eae21edff8ffb99467b19cb12006c524d3921f"
|
|
12
12
|
},
|
|
13
13
|
"exports": [
|
|
14
14
|
".",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"./runtime",
|
|
31
31
|
"./scanner",
|
|
32
32
|
"./scanner/types",
|
|
33
|
+
"./schema",
|
|
33
34
|
"./schema/utils",
|
|
34
35
|
"./smrt-knowledge.json",
|
|
35
36
|
"./table-cache",
|
|
@@ -324,5 +325,5 @@
|
|
|
324
325
|
"polymorphicAssociations": 1,
|
|
325
326
|
"uuidColumns": 3
|
|
326
327
|
},
|
|
327
|
-
"agentDoc": "# @happyvertical/smrt-core\n\nORM, code generation, AI integration, and the DispatchBus. Everything else builds on this.\n\nKey surfaces are `SmrtObject`, `SmrtCollection`, `ObjectRegistry`,\n`DispatchBus`, `GlobalInterceptors`, and `LearningMemory`; the sections below\ndocument their invariants and source locations.\n\n## SmrtObject Lifecycle\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\n## LearningMemory (#1886)\n\n`LearningMemory` provides tenant-isolated, confidence-scored recall over\n`_smrt_contexts` plus optional injected semantic search. `capture()` reinforces\nsuccesses and decays failures while updating outcome counters; `recall()`\napplies confidence, expiry, time-decay, and hierarchical-scope filters and\nrefreshes `last_used_at`. Keep semantic search behind the\n`SmrtCollection.semanticSearch`-compatible injection boundary.\n\n## SmrtCollection Query\n\n```typescript\nawait collection.list({\n where: { status: 'active', price: { op: '>', value: 10 } },\n limit: 50, offset: 0, orderBy: 'created_at DESC'\n});\n```\n\nProjection primitive (#1902): pass `select: ['id', 'title', 'tenantId']` to\n`list()` when an admin/list workflow needs compact rows. `select` uses SMRT\nfield names, maps them to DB columns internally, and returns plain objects keyed\nby the same SMRT field names without hydrating `SmrtObject` instances. It\ncomposes with `where`, `orderBy`, `limit`, and `offset`; `beforeList`\ninterceptors still run. It is for column-backed fields only and cannot combine\nwith `include`/relationship eager loading.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`, `is null`, `is not null`. Arrays auto-detect `IN`. Dot notation for JSON paths: `metadata.userId`.\n\nSTI child collections auto-filter by `_meta_type`.\n\n## Object Memory & Semantic Search\n\nTwo persistence primitives every `SmrtObject`/`SmrtCollection` inherits — load-bearing for learning agents, usable by any object. Full guide: `docs/content/core.md` → \"Context Memory System\".\n\n- **Context memory** (`remember`/`recall`/`recallAll`/`forget`/`forgetScope`, table `_smrt_contexts`): stores any JSON value keyed by `(owner_class, owner_id, scope, key, version)` with a `confidence` score (0–1) and a stored `expiresAt` (metadata — `recall()` does **not** filter expired rows; expiry is caller-managed). `recall()` returns the highest-confidence match with an optional `minConfidence` floor and **opt-in** hierarchical scope fallback (`includeAncestors: true` → `'a/b/c' → 'a/b' → 'a' → 'global'`; default off); `recallAll()` returns a `Map`. Typical use: cache a learned strategy (e.g. a working selector per host) and reuse it across sessions. `success_count`/`failure_count` columns exist for outcome-weighting but are not auto-updated by the framework.\n- **Semantic search** (on `SmrtCollection`, table `_smrt_embeddings`): `semanticSearch(query)`, `findSimilar(object)`, `findSimilarToEmbedding(vector)` — cosine ranking over embeddings of the fields declared in `@smrt({ embeddings })`. Native pgvector/HNSW when configured, in-memory `CosineSimilarity` fallback otherwise; default local model `Xenova/bge-base-en-v1.5` (768-dim) or AI `text-embedding-3-small`. Hits hydrate via `list({ 'id in': … })`, so `@TenantScoped` isolation applies to results.\n\n## @smrt() Decorator Options\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`.\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table, replica-stable — no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows? })` — schedule it. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n## Live Events / Change Signals (#1763, server half)\n\nThe push companion to the change feed (`src/change-signals.ts` + the generated `_events` SSE route) — the server half of live cache invalidation (PRD #1755). The client subscriber (two-client/reconnect/polling-fallback ACs) is a separate later slice.\n\n- **Change-signal bus** (`src/change-signals.ts`): every framework save/delete that appends a durable feed row also publishes a coarse `ChangeSignal` `{ table, operation, rowId, tenantId, seq }` — **never a row payload** (authorization stays on the read path). Structurally mirrors the collection cache's notify/listen path. `subscribeToChangeSignals(db, listener) → unsubscribe`; `publishChangeSignal`/`broadcastChangeSignal`/the listener loop stay internal. `appendChange` now returns the allocated `seq` (was `void`); the signal carries it as a coarse resume cursor (`bumpChangeFeed` ignores the return). The publish runs only after the append SUCCEEDS (no signal without a durable feed row) and in its own log-and-swallow try/catch, so a signal problem never fails the user's write. `_smrt_*` writes never signal (the writer skips them). Delivery is synchronous per-listener with per-listener try/catch (one throwing SSE controller never blocks others); no per-subscriber queue — backpressure rides the platform `ReadableStream`.\n- **In-process + cross-replica**: locally-published and peer-received signals go through the SAME `deliverLocally` path. Cross-replica fan-out rides the db adapter's optional notification capability (`db.notifications`, a NEW `smrt_change_signals` channel distinct from the cache channel) with echo-avoidance by `PROCESS_ID`. No capability → in-process only, warn-once, **never an error, never blocks the write** (subscribers on other replicas fall back to cursor polling).\n- **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\\nevent: change\\ndata: {table,operation,rowId,tenantId}\\n\\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Cross-origin is opt-in and fail-closed (#1861)**: same-origin only by default. REST wraps `_events` in its CORS layer — set `enableCors` + an explicit `allowedOrigins` allowlist + `allowCredentials: true` and an allow-listed browser can subscribe with a credentialed `EventSource` (`withCredentials: true`); the response echoes the specific `Origin` (never `*`) plus `Access-Control-Allow-Credentials: true`. SvelteKit mirrors this via `sveltekit.eventsRoute.allowedOrigins` + `allowCredentials` (the generated route bakes the allowlist into a `Set`, echoes only a member origin, and answers a credentialed `OPTIONS` preflight). CORS never authorizes — the fail-closed auth guard and captured tenant scope are unchanged, so the read posture holds identically across origins; it only lets an allow-listed browser's cookies reach the guard. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.\n- **Known gaps** (documented in the module): raw-SQL writes don't signal (same gap as the feed); live signals for caller-managed-transaction writes are best-effort (the append + signal fire pre-commit, so a rolled-back write may emit a signal and its freed seq is later reused) — the autocommit default path is exact, and clients reconcile via full catch-up/resync (inherits the change feed's transaction caveat).\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Three emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), and the physical `@smrt/web` d.ts (`prebuild/index.ts`).\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n\n## Vite Plugin\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\n## Gotchas\n\n- **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.\n- **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`\n- **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)\n- **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime only verifies and fails clearly\n- **Retry logic**: `db.get()` (3 retries, 250ms) and `db.upsert()` (3 retries, 500ms) have built-in retry\n- **Field caching**: `_cachedFields` populated during `Collection.create()` — eliminates async `getFields()` per query\n- **Smart cloning**: arrays/objects shallow-cloned in property init to prevent aliasing (Issue #22)\n- **Table verification cache**: `isTableVerified(dbUrl, tableName)` avoids redundant `tableExists()` calls\n- **Manifest required**: build-time AST scanning creates manifest. Without vitest plugin → \"No field metadata\"\n- **Vite plugin loads scanner from `dist/` first**: `src/vite-plugin/import-build-aware.ts` prefers `dist/` when it exists on disk; it only falls back to `src/` on fresh clones. So if you edit `src/scanner/*.ts` or `src/schema/generator.ts` and want those edits reflected in consumer manifest generation, you must rebuild (`pnpm build` or have `pnpm dev` / `pnpm build:watch` running in core). This is intentional — sniffing `.ts` vs `.js` via `import.meta.url` was non-deterministic under tsx and broke 12–13 publishes (#1139).\n"
|
|
328
|
+
"agentDoc": "# @happyvertical/smrt-core\n\nORM, code generation, AI integration, and the DispatchBus. Everything else builds on this.\n\nKey surfaces are `SmrtObject`, `SmrtCollection`, `ObjectRegistry`,\n`DispatchBus`, `GlobalInterceptors`, and `LearningMemory`; the sections below\ndocument their invariants and source locations.\n\n## SmrtObject Lifecycle\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\n## LearningMemory (#1886)\n\n`LearningMemory` provides tenant-isolated, confidence-scored recall over\n`_smrt_contexts` plus optional injected semantic search. `capture()` reinforces\nsuccesses and decays failures while updating outcome counters; `recall()`\napplies confidence, expiry, time-decay, and hierarchical-scope filters and\nrefreshes `last_used_at`. Keep semantic search behind the\n`SmrtCollection.semanticSearch`-compatible injection boundary.\n\n## SmrtCollection Query\n\n```typescript\nawait collection.list({\n where: { status: 'active', price: { op: '>', value: 10 } },\n limit: 50, offset: 0, orderBy: 'created_at DESC'\n});\n```\n\nProjection primitive (#1902): pass `select: ['id', 'title', 'tenantId']` to\n`list()` when an admin/list workflow needs compact rows. `select` uses SMRT\nfield names, maps them to DB columns internally, and returns plain objects keyed\nby the same SMRT field names without hydrating `SmrtObject` instances. It\ncomposes with `where`, `orderBy`, `limit`, and `offset`; `beforeList`\ninterceptors still run. It is for column-backed fields only and cannot combine\nwith `include`/relationship eager loading.\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`, `is null`, `is not null`. Arrays auto-detect `IN`. Dot notation for JSON paths: `metadata.userId`.\n\nSTI child collections auto-filter by `_meta_type`.\n\n## Object Memory & Semantic Search\n\nTwo persistence primitives every `SmrtObject`/`SmrtCollection` inherits — load-bearing for learning agents, usable by any object. Full guide: `docs/content/core.md` → \"Context Memory System\".\n\n- **Context memory** (`remember`/`recall`/`recallAll`/`forget`/`forgetScope`, table `_smrt_contexts`): stores any JSON value keyed by `(owner_class, owner_id, scope, key, version)` with a `confidence` score (0–1) and a stored `expiresAt` (metadata — `recall()` does **not** filter expired rows; expiry is caller-managed). `recall()` returns the highest-confidence match with an optional `minConfidence` floor and **opt-in** hierarchical scope fallback (`includeAncestors: true` → `'a/b/c' → 'a/b' → 'a' → 'global'`; default off); `recallAll()` returns a `Map`. Typical use: cache a learned strategy (e.g. a working selector per host) and reuse it across sessions. `success_count`/`failure_count` columns exist for outcome-weighting but are not auto-updated by the framework.\n- **Semantic search** (on `SmrtCollection`, table `_smrt_embeddings`): `semanticSearch(query)`, `findSimilar(object)`, `findSimilarToEmbedding(vector)` — cosine ranking over embeddings of the fields declared in `@smrt({ embeddings })`. Native pgvector/HNSW when configured, in-memory `CosineSimilarity` fallback otherwise; default local model `Xenova/bge-base-en-v1.5` (768-dim) or AI `text-embedding-3-small`. Hits hydrate via `list({ 'id in': … })`, so `@TenantScoped` isolation applies to results.\n\n## @smrt() Decorator Options\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`.\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table, replica-stable — no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows? })` — schedule it. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n## Live Events / Change Signals (#1763, server half)\n\nThe push companion to the change feed (`src/change-signals.ts` + the generated `_events` SSE route) — the server half of live cache invalidation (PRD #1755). The client subscriber (two-client/reconnect/polling-fallback ACs) is a separate later slice.\n\n- **Change-signal bus** (`src/change-signals.ts`): every framework save/delete that appends a durable feed row also publishes a coarse `ChangeSignal` `{ table, operation, rowId, tenantId, seq }` — **never a row payload** (authorization stays on the read path). Structurally mirrors the collection cache's notify/listen path. `subscribeToChangeSignals(db, listener) → unsubscribe`; `publishChangeSignal`/`broadcastChangeSignal`/the listener loop stay internal. `appendChange` now returns the allocated `seq` (was `void`); the signal carries it as a coarse resume cursor (`bumpChangeFeed` ignores the return). The publish runs only after the append SUCCEEDS (no signal without a durable feed row) and in its own log-and-swallow try/catch, so a signal problem never fails the user's write. `_smrt_*` writes never signal (the writer skips them). Delivery is synchronous per-listener with per-listener try/catch (one throwing SSE controller never blocks others); no per-subscriber queue — backpressure rides the platform `ReadableStream`.\n- **In-process + cross-replica**: locally-published and peer-received signals go through the SAME `deliverLocally` path. Cross-replica fan-out rides the db adapter's optional notification capability (`db.notifications`, a NEW `smrt_change_signals` channel distinct from the cache channel) with echo-avoidance by `PROCESS_ID`. No capability → in-process only, warn-once, **never an error, never blocks the write** (subscribers on other replicas fall back to cursor polling).\n- **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\\nevent: change\\ndata: {table,operation,rowId,tenantId}\\n\\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Cross-origin is opt-in and fail-closed (#1861)**: same-origin only by default. REST wraps `_events` in its CORS layer — set `enableCors` + an explicit `allowedOrigins` allowlist + `allowCredentials: true` and an allow-listed browser can subscribe with a credentialed `EventSource` (`withCredentials: true`); the response echoes the specific `Origin` (never `*`) plus `Access-Control-Allow-Credentials: true`. SvelteKit mirrors this via `sveltekit.eventsRoute.allowedOrigins` + `allowCredentials` (the generated route bakes the allowlist into a `Set`, echoes only a member origin, and answers a credentialed `OPTIONS` preflight). CORS never authorizes — the fail-closed auth guard and captured tenant scope are unchanged, so the read posture holds identically across origins; it only lets an allow-listed browser's cookies reach the guard. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.\n- **Known gaps** (documented in the module): raw-SQL writes don't signal (same gap as the feed); live signals for caller-managed-transaction writes are best-effort (the append + signal fire pre-commit, so a rolled-back write may emit a signal and its freed seq is later reused) — the autocommit default path is exact, and clients reconcile via full catch-up/resync (inherits the change feed's transaction caveat).\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Three emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), and the physical `@smrt/web` d.ts (`prebuild/index.ts`).\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n\n## Vite Plugin\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\n## Gotchas\n\n- **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.\n- **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`\n- **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)\n- **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime only verifies and fails clearly\n- **Retry logic**: `db.get()` (3 retries, 250ms) and `db.upsert()` (3 retries, 500ms) have built-in retry\n- **Field caching**: `_cachedFields` populated during `Collection.create()` — eliminates async `getFields()` per query\n- **Smart cloning**: arrays/objects shallow-cloned in property init to prevent aliasing (Issue #22)\n- **Table verification cache**: `isTableVerified(dbUrl, tableName)` avoids redundant `tableExists()` calls\n- **Manifest required**: build-time AST scanning creates manifest. Without vitest plugin → \"No field metadata\"\n- **Vite plugin loads scanner from `dist/` first**: `src/vite-plugin/import-build-aware.ts` prefers `dist/` when it exists on disk; it only falls back to `src/` on fresh clones. So if you edit `src/scanner/*.ts` or `src/schema/generator.ts` and want those edits reflected in consumer manifest generation, you must rebuild (`pnpm build` or have `pnpm dev` / `pnpm build:watch` running in core). This is intentional — sniffing `.ts` vs `.js` via `import.meta.url` was non-deterministic under tsx and broke 12–13 publishes (#1139).\n"
|
|
328
329
|
}
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import { detectEngine } from '../schema/ddl/index.js';
|
|
2
3
|
export declare function tableExists(db: DatabaseInterface, tableName: string, typeHint?: string): Promise<boolean>;
|
|
4
|
+
export declare function getDatabaseEngine(db: DatabaseInterface, typeHint?: string): ReturnType<typeof detectEngine>;
|
|
5
|
+
/**
|
|
6
|
+
* Upgrade framework-owned PostgreSQL timestamp columns to instant-safe storage.
|
|
7
|
+
*
|
|
8
|
+
* Legacy SMRT and system writers serialized instants as UTC wall times. The
|
|
9
|
+
* conversion is intentionally fail-closed unless the migration session is UTC:
|
|
10
|
+
* operators must first confirm that historical database-default/raw writers
|
|
11
|
+
* also used UTC. A deployment with non-UTC legacy writers needs an explicit,
|
|
12
|
+
* provenance-aware data migration rather than a guessed offset.
|
|
13
|
+
*/
|
|
14
|
+
export interface PostgresSystemTimestampMigrationConfirmation {
|
|
15
|
+
/**
|
|
16
|
+
* Explicitly confirmed timezone used by every historical writer of legacy
|
|
17
|
+
* timezone-naive system timestamps. Only UTC is supported by this helper.
|
|
18
|
+
*/
|
|
19
|
+
legacyTimezone: 'UTC';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fail closed when framework-owned PostgreSQL tables still contain legacy
|
|
23
|
+
* timezone-naive timestamps. Call this before recording the current system
|
|
24
|
+
* schema version so a partial installation cannot be stamped as upgraded.
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertPostgresSystemTimestampsCurrent(db: DatabaseInterface, typeHint?: string): Promise<void>;
|
|
27
|
+
export declare function migratePostgresSystemTimestamps(db: DatabaseInterface, confirmation: PostgresSystemTimestampMigrationConfirmation, typeHint?: string): Promise<void>;
|
|
3
28
|
export declare function ensureDispatchSystemTableCompatibility(db: DatabaseInterface, typeHint?: string): Promise<void>;
|
|
4
29
|
export declare function ensureDispatchSubscriptionsSystemTableCompatibility(db: DatabaseInterface, typeHint?: string): Promise<void>;
|
|
5
30
|
export declare function ensureJobsSystemTableCompatibility(db: DatabaseInterface, typeHint?: string): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compatibility.d.ts","sourceRoot":"","sources":["../../src/system/compatibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"compatibility.d.ts","sourceRoot":"","sources":["../../src/system/compatibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AA6BtD,wBAAsB,WAAW,CAC/B,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC,CAgBlB;AA+BD,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,UAAU,CAAC,OAAO,YAAY,CAAC,CAMjC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,4CAA4C;IAC3D;;;OAGG;IACH,cAAc,EAAE,KAAK,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,qCAAqC,CACzD,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAqBf;AAED,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,iBAAiB,EACrB,YAAY,EAAE,4CAA4C,EAC1D,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CA+Cf;AAyUD,wBAAsB,sCAAsC,CAC1D,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CA4Cf;AAED,wBAAsB,mDAAmD,CACvE,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED,wBAAsB,kCAAkC,CACtD,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAaf;AAED,wBAAsB,uCAAuC,CAC3D,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAwCf;AAED,wBAAsB,oCAAoC,CACxD,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAKf"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectEngine } from "../schema/ddl/index.js";
|
|
2
|
+
import { CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY } from "./schema.js";
|
|
2
3
|
//#region src/system/compatibility.ts
|
|
3
4
|
function getQueryRows(result) {
|
|
4
5
|
if (Array.isArray(result)) return result;
|
|
@@ -33,6 +34,65 @@ function getDatabaseEngine(db, typeHint) {
|
|
|
33
34
|
const dbWithConfig = db;
|
|
34
35
|
return detectEngine(getDatabaseUrl(db), typeHint || dbWithConfig.type || dbWithConfig.config?.type);
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Fail closed when framework-owned PostgreSQL tables still contain legacy
|
|
39
|
+
* timezone-naive timestamps. Call this before recording the current system
|
|
40
|
+
* schema version so a partial installation cannot be stamped as upgraded.
|
|
41
|
+
*/
|
|
42
|
+
async function assertPostgresSystemTimestampsCurrent(db, typeHint) {
|
|
43
|
+
if (getDatabaseEngine(db, typeHint) !== "postgres") return;
|
|
44
|
+
const rows = getQueryRows(await db.query(`
|
|
45
|
+
SELECT table_name, column_name
|
|
46
|
+
FROM information_schema.columns
|
|
47
|
+
WHERE table_schema = current_schema()
|
|
48
|
+
AND table_name LIKE '\\_smrt\\_%' ESCAPE '\\'
|
|
49
|
+
AND data_type = 'timestamp without time zone'
|
|
50
|
+
ORDER BY table_name, ordinal_position
|
|
51
|
+
`));
|
|
52
|
+
if (rows.length === 0) return;
|
|
53
|
+
const columns = rows.map((row) => `${String(row.table_name)}.${String(row.column_name)}`).join(", ");
|
|
54
|
+
throw new Error(`Legacy SMRT system timestamps remain (${columns}); run the explicit audited migratePostgresSystemTimestamps() migration before bootstrap`);
|
|
55
|
+
}
|
|
56
|
+
async function migratePostgresSystemTimestamps(db, confirmation, typeHint) {
|
|
57
|
+
if (getDatabaseEngine(db, typeHint) !== "postgres") return;
|
|
58
|
+
if (confirmation.legacyTimezone !== "UTC") throw new Error("Legacy SMRT system timestamp migration requires an explicit UTC provenance confirmation");
|
|
59
|
+
await db.query(`
|
|
60
|
+
DO $smrt_migrate_system_timestamps$
|
|
61
|
+
DECLARE
|
|
62
|
+
target RECORD;
|
|
63
|
+
BEGIN
|
|
64
|
+
PERFORM pg_advisory_xact_lock(
|
|
65
|
+
hashtext('smrt'),
|
|
66
|
+
hashtext('system-tables')
|
|
67
|
+
);
|
|
68
|
+
IF upper(current_setting('TimeZone')) NOT IN ('UTC', 'ETC/UTC', 'GMT') THEN
|
|
69
|
+
RAISE EXCEPTION 'Refusing to reinterpret legacy SMRT timestamps outside a UTC PostgreSQL session; confirm historical writers used UTC and SET TIME ZONE UTC';
|
|
70
|
+
END IF;
|
|
71
|
+
FOR target IN
|
|
72
|
+
SELECT table_name, column_name
|
|
73
|
+
FROM information_schema.columns
|
|
74
|
+
WHERE table_schema = current_schema()
|
|
75
|
+
AND table_name LIKE '\\_smrt\\_%' ESCAPE '\\'
|
|
76
|
+
AND data_type = 'timestamp without time zone'
|
|
77
|
+
ORDER BY table_name, ordinal_position
|
|
78
|
+
LOOP
|
|
79
|
+
EXECUTE format(
|
|
80
|
+
'ALTER TABLE %I ALTER COLUMN %I TYPE TIMESTAMPTZ USING %I AT TIME ZONE ''UTC''',
|
|
81
|
+
target.table_name,
|
|
82
|
+
target.column_name,
|
|
83
|
+
target.column_name
|
|
84
|
+
);
|
|
85
|
+
END LOOP;
|
|
86
|
+
IF to_regclass('_smrt_changes') IS NOT NULL THEN
|
|
87
|
+
DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};
|
|
88
|
+
EXECUTE $smrt_change_feed_ddl$
|
|
89
|
+
${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}
|
|
90
|
+
$smrt_change_feed_ddl$;
|
|
91
|
+
END IF;
|
|
92
|
+
END;
|
|
93
|
+
$smrt_migrate_system_timestamps$;
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
36
96
|
function isDuplicateColumnError(error) {
|
|
37
97
|
const message = error && typeof error === "object" ? String(error.message || error) : String(error);
|
|
38
98
|
return /column .*already exists/i.test(message) || /duplicate column name/i.test(message);
|
|
@@ -231,6 +291,6 @@ async function ensureLegacySystemTableCompatibility(db, typeHint) {
|
|
|
231
291
|
await ensureJobEventsSystemTableCompatibility(db, typeHint);
|
|
232
292
|
}
|
|
233
293
|
//#endregion
|
|
234
|
-
export { ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, tableExists };
|
|
294
|
+
export { assertPostgresSystemTimestampsCurrent, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, getDatabaseEngine, migratePostgresSystemTimestamps, tableExists };
|
|
235
295
|
|
|
236
296
|
//# sourceMappingURL=compatibility.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compatibility.js","names":[],"sources":["../../src/system/compatibility.ts"],"sourcesContent":["import type { DatabaseInterface } from '@happyvertical/sql';\nimport { detectEngine } from '../schema/ddl/index.js';\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: {\n type?: string;\n url?: string;\n };\n type?: string;\n};\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n\n return [];\n}\n\nexport async function tableExists(\n db: DatabaseInterface,\n tableName: string,\n typeHint?: string,\n): Promise<boolean> {\n try {\n const engine = getDatabaseEngine(db, typeHint);\n if (engine === 'postgres') {\n const result = await db.query(\n 'SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1 LIMIT 1',\n tableName,\n );\n return getQueryRows(result).length > 0;\n }\n\n await db.query(`SELECT 1 FROM ${tableName} LIMIT 1`);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function columnExists(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n typeHint?: string,\n): Promise<boolean> {\n try {\n const engine = getDatabaseEngine(db, typeHint);\n if (engine === 'postgres') {\n const result = await db.query(\n 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1',\n tableName,\n columnName,\n );\n return getQueryRows(result).length > 0;\n }\n\n const result = await db.query(`PRAGMA table_info(${tableName})`);\n return getQueryRows(result).some((row) => row.name === columnName);\n } catch {\n return false;\n }\n}\n\nfunction getDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseWithConfig;\n return db.url || dbWithConfig.config?.url || '';\n}\n\nfunction getDatabaseEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const dbWithConfig = db as DatabaseWithConfig;\n return detectEngine(\n getDatabaseUrl(db),\n typeHint || dbWithConfig.type || dbWithConfig.config?.type,\n );\n}\n\nfunction isDuplicateColumnError(error: unknown): boolean {\n const message =\n error && typeof error === 'object'\n ? String((error as { message?: unknown }).message || error)\n : String(error);\n\n return (\n /column .*already exists/i.test(message) ||\n /duplicate column name/i.test(message)\n );\n}\n\nfunction collectErrorDetails(\n error: unknown,\n seen = new Set<unknown>(),\n): {\n codes: Set<string>;\n messages: string[];\n} {\n const codes = new Set<string>();\n const messages: string[] = [];\n\n if (!error || seen.has(error)) {\n return { codes, messages };\n }\n\n seen.add(error);\n\n if (typeof error === 'object') {\n const errorRecord = error as Record<string, unknown>;\n\n if (typeof errorRecord.code === 'string') {\n codes.add(errorRecord.code);\n }\n\n for (const messageKey of ['message', 'detail']) {\n if (typeof errorRecord[messageKey] === 'string') {\n messages.push(errorRecord[messageKey]);\n }\n }\n\n for (const nestedKey of ['cause', 'context', 'originalError']) {\n const nested = collectErrorDetails(errorRecord[nestedKey], seen);\n for (const code of nested.codes) {\n codes.add(code);\n }\n messages.push(...nested.messages);\n }\n } else {\n messages.push(String(error));\n }\n\n return { codes, messages };\n}\n\nfunction isDuplicateIndexRaceError(error: unknown, indexName: string): boolean {\n const { codes, messages } = collectErrorDetails(error);\n const message = messages.join('\\n').toLowerCase();\n const normalizedIndexName = indexName.toLowerCase();\n const hasDuplicateCode =\n codes.has('23505') || /\\bcode\\s*=\\s*23505\\b/.test(message);\n const hasRelationExistsCode =\n codes.has('42P07') || /\\bcode\\s*=\\s*42p07\\b/i.test(message);\n\n if (!message.includes(normalizedIndexName)) {\n return false;\n }\n\n if (hasDuplicateCode && message.includes('pg_class_relname_nsp_index')) {\n return true;\n }\n\n if (hasRelationExistsCode && /relation .*already exists/i.test(message)) {\n return true;\n }\n\n return false;\n}\n\nasync function addColumnIfMissing(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n definition: string,\n typeHint?: string,\n): Promise<void> {\n const engine = getDatabaseEngine(db, typeHint);\n\n if (engine === 'postgres') {\n if (await columnExists(db, tableName, columnName, typeHint)) {\n return;\n }\n\n await db.query(\n `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${columnName} ${definition}`,\n );\n return;\n }\n\n if (await columnExists(db, tableName, columnName, typeHint)) {\n return;\n }\n\n try {\n await db.query(\n `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`,\n );\n } catch (error) {\n if (isDuplicateColumnError(error)) {\n return;\n }\n\n throw error;\n }\n}\n\nasync function indexExists(\n db: DatabaseInterface,\n indexName: string,\n typeHint?: string,\n): Promise<boolean> {\n try {\n const engine = getDatabaseEngine(db, typeHint);\n if (engine === 'postgres') {\n const result = await db.query(\n 'SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = $1 LIMIT 1',\n indexName,\n );\n return getQueryRows(result).length > 0;\n }\n\n const result = await db.query(\n `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`,\n indexName,\n );\n return getQueryRows(result).length > 0;\n } catch {\n return false;\n }\n}\n\nasync function addIndexIfMissing(\n db: DatabaseInterface,\n indexName: string,\n tableName: string,\n columnName: string,\n typeHint?: string,\n): Promise<void> {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n\n try {\n await db.query(\n `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${columnName})`,\n );\n } catch (error) {\n if (isDuplicateIndexRaceError(error, indexName)) {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n }\n\n throw error;\n }\n}\n\nasync function addUniqueIndexIfMissing(\n db: DatabaseInterface,\n indexName: string,\n tableName: string,\n columns: string,\n typeHint?: string,\n): Promise<void> {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n\n try {\n await db.query(\n `CREATE UNIQUE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${columns})`,\n );\n } catch (error) {\n if (isDuplicateIndexRaceError(error, indexName)) {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n }\n\n throw error;\n }\n}\n\n/**\n * Migrate the legacy `UNIQUE(signal_type, subscriber)` subscription identity to\n * the tenant-scoped `UNIQUE(tenant_id, signal_type, subscriber)` (S5 #1398).\n *\n * The old identity let any tenant overwrite/delete/enable another tenant's\n * subscription because the natural key ignored the tenant. This:\n * 1. Creates the tenant-scoped unique index used by upsert conflict handling.\n * 2. Drops the legacy 2-column unique constraint:\n * - **SQLite**: inline `UNIQUE(...)` constraints can't be dropped in place,\n * so the table is rebuilt without it (data preserved). The legacy\n * constraint surfaces as an auto-created `sqlite_autoindex_*` index.\n * - **Postgres**: the inline constraint is dropped by its conventional\n * name if present.\n *\n * Additive and idempotent: re-running is a no-op once migrated.\n */\nasync function migrateDispatchSubscriptionsIdentity(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n const engine = getDatabaseEngine(db, typeHint);\n\n // 1. Tenant-scoped unique index (used by upsert's ON CONFLICT target).\n await addUniqueIndexIfMissing(\n db,\n 'uq_smrt_dispatch_subs_tenant_signal_subscriber',\n '_smrt_dispatch_subscriptions',\n 'tenant_id, signal_type, subscriber',\n typeHint,\n );\n\n // 2. Drop the legacy (signal_type, subscriber) uniqueness.\n if (engine === 'postgres') {\n // Inline table-level UNIQUE constraints get a conventional name. Drop it if\n // present; ignore if it was never created under that name.\n try {\n await db.query(\n 'ALTER TABLE _smrt_dispatch_subscriptions DROP CONSTRAINT IF EXISTS _smrt_dispatch_subscriptions_signal_type_subscriber_key',\n );\n } catch {\n // Best-effort: a differently-named legacy constraint is left in place; the\n // tenant-scoped index above still enforces correct per-tenant identity.\n }\n return;\n }\n\n // SQLite: detect a legacy auto-index over exactly (signal_type, subscriber)\n // and, if found, rebuild the table without the inline UNIQUE.\n if (engine !== 'sqlite') {\n return;\n }\n\n let legacyAutoIndex: string | null = null;\n try {\n const result = await db.query(\n `SELECT name FROM sqlite_master\n WHERE type = 'index'\n AND tbl_name = '_smrt_dispatch_subscriptions'\n AND name LIKE 'sqlite_autoindex_%'`,\n );\n for (const row of getQueryRows(result)) {\n const indexName = String(row.name);\n const info = await db.query(`PRAGMA index_info(${indexName})`);\n const cols = getQueryRows(info)\n .map((r) => String(r.name))\n .sort();\n if (\n cols.length === 2 &&\n cols[0] === 'signal_type' &&\n cols[1] === 'subscriber'\n ) {\n legacyAutoIndex = indexName;\n break;\n }\n }\n } catch {\n // Unable to introspect → assume no legacy constraint (fresh tables created\n // from the new schema have none).\n legacyAutoIndex = null;\n }\n\n if (!legacyAutoIndex) {\n return;\n }\n\n // Rebuild the table without the inline UNIQUE(signal_type, subscriber).\n // System table is small; this runs once per legacy database.\n await db.query('PRAGMA foreign_keys=OFF');\n try {\n await db.query(`\n CREATE TABLE _smrt_dispatch_subscriptions_new (\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 await db.query(`\n INSERT INTO _smrt_dispatch_subscriptions_new\n (id, signal_type, subscriber, handler, delivery, enabled, tenant_id, created_at, updated_at)\n SELECT id, signal_type, subscriber, handler, delivery, enabled, tenant_id, created_at, updated_at\n FROM _smrt_dispatch_subscriptions\n `);\n await db.query('DROP TABLE _smrt_dispatch_subscriptions');\n await db.query(\n 'ALTER TABLE _smrt_dispatch_subscriptions_new RENAME TO _smrt_dispatch_subscriptions',\n );\n // Recreate the secondary indexes (the rebuild dropped them) plus the\n // tenant-scoped unique index.\n await db.query(\n 'CREATE UNIQUE INDEX IF NOT EXISTS uq_smrt_dispatch_subs_tenant_signal_subscriber ON _smrt_dispatch_subscriptions(tenant_id, signal_type, subscriber)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_subscriber ON _smrt_dispatch_subscriptions(subscriber)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_tenant_id ON _smrt_dispatch_subscriptions(tenant_id)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_signal_type ON _smrt_dispatch_subscriptions(signal_type)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_enabled ON _smrt_dispatch_subscriptions(enabled)',\n );\n } finally {\n await db.query('PRAGMA foreign_keys=ON');\n }\n}\n\nexport async function ensureDispatchSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_dispatch', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(\n db,\n '_smrt_dispatch',\n 'target_subscriber',\n 'TEXT',\n typeHint,\n );\n await addColumnIfMissing(\n db,\n '_smrt_dispatch',\n 'correlation_id',\n 'TEXT',\n typeHint,\n );\n // Tenant isolation (S5 #1398): dispatches are scoped to the emitting\n // tenant context so a subscriber in tenant A cannot snoop/claim tenant B's\n // dispatches. Nullable so non-tenant (global) dispatches keep tenant_id NULL.\n await addColumnIfMissing(db, '_smrt_dispatch', 'tenant_id', 'TEXT', typeHint);\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_target',\n '_smrt_dispatch',\n 'target_subscriber',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_correlation',\n '_smrt_dispatch',\n 'correlation_id',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_tenant_id',\n '_smrt_dispatch',\n 'tenant_id',\n typeHint,\n );\n}\n\nexport async function ensureDispatchSubscriptionsSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_dispatch_subscriptions', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(\n db,\n '_smrt_dispatch_subscriptions',\n 'delivery',\n \"TEXT NOT NULL DEFAULT 'compete'\",\n typeHint,\n );\n // Tenant isolation (S5 #1398): records the tenant context a subscription was\n // registered in. Nullable so global/non-tenant subscriptions keep it NULL.\n await addColumnIfMissing(\n db,\n '_smrt_dispatch_subscriptions',\n 'tenant_id',\n 'TEXT',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_subs_tenant_id',\n '_smrt_dispatch_subscriptions',\n 'tenant_id',\n typeHint,\n );\n // Migrate the subscription natural key to (tenant_id, signal_type, subscriber)\n // so tenants can't clobber each other's subscriptions (S5 #1398).\n await migrateDispatchSubscriptionsIdentity(db, typeHint);\n}\n\nexport async function ensureJobsSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_jobs', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(db, '_smrt_jobs', 'tenant_id', 'TEXT', typeHint);\n await addIndexIfMissing(\n db,\n 'idx_smrt_jobs_tenant_id',\n '_smrt_jobs',\n 'tenant_id',\n typeHint,\n );\n}\n\nexport async function ensureJobEventsSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_job_events', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(\n db,\n '_smrt_job_events',\n 'tenant_id',\n 'TEXT',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_tenant_id',\n '_smrt_job_events',\n 'tenant_id',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_job_id',\n '_smrt_job_events',\n 'job_id',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_type',\n '_smrt_job_events',\n 'type',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_created_at',\n '_smrt_job_events',\n 'created_at',\n typeHint,\n );\n}\n\nexport async function ensureLegacySystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n await ensureDispatchSystemTableCompatibility(db, typeHint);\n await ensureDispatchSubscriptionsSystemTableCompatibility(db, typeHint);\n await ensureJobsSystemTableCompatibility(db, typeHint);\n await ensureJobEventsSystemTableCompatibility(db, typeHint);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"compatibility.js","names":[],"sources":["../../src/system/compatibility.ts"],"sourcesContent":["import type { DatabaseInterface } from '@happyvertical/sql';\nimport { detectEngine } from '../schema/ddl/index.js';\nimport {\n CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION,\n LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY,\n} from './schema.js';\n\ntype DatabaseWithConfig = DatabaseInterface & {\n config?: {\n type?: string;\n url?: string;\n };\n type?: string;\n};\n\nfunction getQueryRows(result: unknown): Record<string, unknown>[] {\n if (Array.isArray(result)) {\n return result as Record<string, unknown>[];\n }\n\n if (result && typeof result === 'object' && 'rows' in result) {\n const rows = (result as { rows?: unknown }).rows;\n if (Array.isArray(rows)) {\n return rows as Record<string, unknown>[];\n }\n }\n\n return [];\n}\n\nexport async function tableExists(\n db: DatabaseInterface,\n tableName: string,\n typeHint?: string,\n): Promise<boolean> {\n try {\n const engine = getDatabaseEngine(db, typeHint);\n if (engine === 'postgres') {\n const result = await db.query(\n 'SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1 LIMIT 1',\n tableName,\n );\n return getQueryRows(result).length > 0;\n }\n\n await db.query(`SELECT 1 FROM ${tableName} LIMIT 1`);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function columnExists(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n typeHint?: string,\n): Promise<boolean> {\n try {\n const engine = getDatabaseEngine(db, typeHint);\n if (engine === 'postgres') {\n const result = await db.query(\n 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1',\n tableName,\n columnName,\n );\n return getQueryRows(result).length > 0;\n }\n\n const result = await db.query(`PRAGMA table_info(${tableName})`);\n return getQueryRows(result).some((row) => row.name === columnName);\n } catch {\n return false;\n }\n}\n\nfunction getDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseWithConfig;\n return db.url || dbWithConfig.config?.url || '';\n}\n\nexport function getDatabaseEngine(\n db: DatabaseInterface,\n typeHint?: string,\n): ReturnType<typeof detectEngine> {\n const dbWithConfig = db as DatabaseWithConfig;\n return detectEngine(\n getDatabaseUrl(db),\n typeHint || dbWithConfig.type || dbWithConfig.config?.type,\n );\n}\n\n/**\n * Upgrade framework-owned PostgreSQL timestamp columns to instant-safe storage.\n *\n * Legacy SMRT and system writers serialized instants as UTC wall times. The\n * conversion is intentionally fail-closed unless the migration session is UTC:\n * operators must first confirm that historical database-default/raw writers\n * also used UTC. A deployment with non-UTC legacy writers needs an explicit,\n * provenance-aware data migration rather than a guessed offset.\n */\nexport interface PostgresSystemTimestampMigrationConfirmation {\n /**\n * Explicitly confirmed timezone used by every historical writer of legacy\n * timezone-naive system timestamps. Only UTC is supported by this helper.\n */\n legacyTimezone: 'UTC';\n}\n\n/**\n * Fail closed when framework-owned PostgreSQL tables still contain legacy\n * timezone-naive timestamps. Call this before recording the current system\n * schema version so a partial installation cannot be stamped as upgraded.\n */\nexport async function assertPostgresSystemTimestampsCurrent(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (getDatabaseEngine(db, typeHint) !== 'postgres') return;\n\n const rows = getQueryRows(\n await db.query(`\n SELECT table_name, column_name\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name LIKE '\\\\_smrt\\\\_%' ESCAPE '\\\\'\n AND data_type = 'timestamp without time zone'\n ORDER BY table_name, ordinal_position\n `),\n );\n if (rows.length === 0) return;\n\n const columns = rows\n .map((row) => `${String(row.table_name)}.${String(row.column_name)}`)\n .join(', ');\n throw new Error(\n `Legacy SMRT system timestamps remain (${columns}); run the explicit audited migratePostgresSystemTimestamps() migration before bootstrap`,\n );\n}\n\nexport async function migratePostgresSystemTimestamps(\n db: DatabaseInterface,\n confirmation: PostgresSystemTimestampMigrationConfirmation,\n typeHint?: string,\n): Promise<void> {\n if (getDatabaseEngine(db, typeHint) !== 'postgres') return;\n if (confirmation.legacyTimezone !== 'UTC') {\n throw new Error(\n 'Legacy SMRT system timestamp migration requires an explicit UTC provenance confirmation',\n );\n }\n\n // One server-side statement makes the catalog change atomic and holds the\n // same transaction-scoped lock used by system bootstrap. Any failing column\n // rolls back every preceding ALTER in the block.\n await db.query(`\n DO $smrt_migrate_system_timestamps$\n DECLARE\n target RECORD;\n BEGIN\n PERFORM pg_advisory_xact_lock(\n hashtext('smrt'),\n hashtext('system-tables')\n );\n IF upper(current_setting('TimeZone')) NOT IN ('UTC', 'ETC/UTC', 'GMT') THEN\n RAISE EXCEPTION 'Refusing to reinterpret legacy SMRT timestamps outside a UTC PostgreSQL session; confirm historical writers used UTC and SET TIME ZONE UTC';\n END IF;\n FOR target IN\n SELECT table_name, column_name\n FROM information_schema.columns\n WHERE table_schema = current_schema()\n AND table_name LIKE '\\\\_smrt\\\\_%' ESCAPE '\\\\'\n AND data_type = 'timestamp without time zone'\n ORDER BY table_name, ordinal_position\n LOOP\n EXECUTE format(\n 'ALTER TABLE %I ALTER COLUMN %I TYPE TIMESTAMPTZ USING %I AT TIME ZONE ''UTC''',\n target.table_name,\n target.column_name,\n target.column_name\n );\n END LOOP;\n IF to_regclass('_smrt_changes') IS NOT NULL THEN\n DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};\n EXECUTE $smrt_change_feed_ddl$\n${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}\n$smrt_change_feed_ddl$;\n END IF;\n END;\n $smrt_migrate_system_timestamps$;\n `);\n}\n\nfunction isDuplicateColumnError(error: unknown): boolean {\n const message =\n error && typeof error === 'object'\n ? String((error as { message?: unknown }).message || error)\n : String(error);\n\n return (\n /column .*already exists/i.test(message) ||\n /duplicate column name/i.test(message)\n );\n}\n\nfunction collectErrorDetails(\n error: unknown,\n seen = new Set<unknown>(),\n): {\n codes: Set<string>;\n messages: string[];\n} {\n const codes = new Set<string>();\n const messages: string[] = [];\n\n if (!error || seen.has(error)) {\n return { codes, messages };\n }\n\n seen.add(error);\n\n if (typeof error === 'object') {\n const errorRecord = error as Record<string, unknown>;\n\n if (typeof errorRecord.code === 'string') {\n codes.add(errorRecord.code);\n }\n\n for (const messageKey of ['message', 'detail']) {\n if (typeof errorRecord[messageKey] === 'string') {\n messages.push(errorRecord[messageKey]);\n }\n }\n\n for (const nestedKey of ['cause', 'context', 'originalError']) {\n const nested = collectErrorDetails(errorRecord[nestedKey], seen);\n for (const code of nested.codes) {\n codes.add(code);\n }\n messages.push(...nested.messages);\n }\n } else {\n messages.push(String(error));\n }\n\n return { codes, messages };\n}\n\nfunction isDuplicateIndexRaceError(error: unknown, indexName: string): boolean {\n const { codes, messages } = collectErrorDetails(error);\n const message = messages.join('\\n').toLowerCase();\n const normalizedIndexName = indexName.toLowerCase();\n const hasDuplicateCode =\n codes.has('23505') || /\\bcode\\s*=\\s*23505\\b/.test(message);\n const hasRelationExistsCode =\n codes.has('42P07') || /\\bcode\\s*=\\s*42p07\\b/i.test(message);\n\n if (!message.includes(normalizedIndexName)) {\n return false;\n }\n\n if (hasDuplicateCode && message.includes('pg_class_relname_nsp_index')) {\n return true;\n }\n\n if (hasRelationExistsCode && /relation .*already exists/i.test(message)) {\n return true;\n }\n\n return false;\n}\n\nasync function addColumnIfMissing(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n definition: string,\n typeHint?: string,\n): Promise<void> {\n const engine = getDatabaseEngine(db, typeHint);\n\n if (engine === 'postgres') {\n if (await columnExists(db, tableName, columnName, typeHint)) {\n return;\n }\n\n await db.query(\n `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${columnName} ${definition}`,\n );\n return;\n }\n\n if (await columnExists(db, tableName, columnName, typeHint)) {\n return;\n }\n\n try {\n await db.query(\n `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`,\n );\n } catch (error) {\n if (isDuplicateColumnError(error)) {\n return;\n }\n\n throw error;\n }\n}\n\nasync function indexExists(\n db: DatabaseInterface,\n indexName: string,\n typeHint?: string,\n): Promise<boolean> {\n try {\n const engine = getDatabaseEngine(db, typeHint);\n if (engine === 'postgres') {\n const result = await db.query(\n 'SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = $1 LIMIT 1',\n indexName,\n );\n return getQueryRows(result).length > 0;\n }\n\n const result = await db.query(\n `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`,\n indexName,\n );\n return getQueryRows(result).length > 0;\n } catch {\n return false;\n }\n}\n\nasync function addIndexIfMissing(\n db: DatabaseInterface,\n indexName: string,\n tableName: string,\n columnName: string,\n typeHint?: string,\n): Promise<void> {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n\n try {\n await db.query(\n `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${columnName})`,\n );\n } catch (error) {\n if (isDuplicateIndexRaceError(error, indexName)) {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n }\n\n throw error;\n }\n}\n\nasync function addUniqueIndexIfMissing(\n db: DatabaseInterface,\n indexName: string,\n tableName: string,\n columns: string,\n typeHint?: string,\n): Promise<void> {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n\n try {\n await db.query(\n `CREATE UNIQUE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${columns})`,\n );\n } catch (error) {\n if (isDuplicateIndexRaceError(error, indexName)) {\n if (await indexExists(db, indexName, typeHint)) {\n return;\n }\n }\n\n throw error;\n }\n}\n\n/**\n * Migrate the legacy `UNIQUE(signal_type, subscriber)` subscription identity to\n * the tenant-scoped `UNIQUE(tenant_id, signal_type, subscriber)` (S5 #1398).\n *\n * The old identity let any tenant overwrite/delete/enable another tenant's\n * subscription because the natural key ignored the tenant. This:\n * 1. Creates the tenant-scoped unique index used by upsert conflict handling.\n * 2. Drops the legacy 2-column unique constraint:\n * - **SQLite**: inline `UNIQUE(...)` constraints can't be dropped in place,\n * so the table is rebuilt without it (data preserved). The legacy\n * constraint surfaces as an auto-created `sqlite_autoindex_*` index.\n * - **Postgres**: the inline constraint is dropped by its conventional\n * name if present.\n *\n * Additive and idempotent: re-running is a no-op once migrated.\n */\nasync function migrateDispatchSubscriptionsIdentity(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n const engine = getDatabaseEngine(db, typeHint);\n\n // 1. Tenant-scoped unique index (used by upsert's ON CONFLICT target).\n await addUniqueIndexIfMissing(\n db,\n 'uq_smrt_dispatch_subs_tenant_signal_subscriber',\n '_smrt_dispatch_subscriptions',\n 'tenant_id, signal_type, subscriber',\n typeHint,\n );\n\n // 2. Drop the legacy (signal_type, subscriber) uniqueness.\n if (engine === 'postgres') {\n // Inline table-level UNIQUE constraints get a conventional name. Drop it if\n // present; ignore if it was never created under that name.\n try {\n await db.query(\n 'ALTER TABLE _smrt_dispatch_subscriptions DROP CONSTRAINT IF EXISTS _smrt_dispatch_subscriptions_signal_type_subscriber_key',\n );\n } catch {\n // Best-effort: a differently-named legacy constraint is left in place; the\n // tenant-scoped index above still enforces correct per-tenant identity.\n }\n return;\n }\n\n // SQLite: detect a legacy auto-index over exactly (signal_type, subscriber)\n // and, if found, rebuild the table without the inline UNIQUE.\n if (engine !== 'sqlite') {\n return;\n }\n\n let legacyAutoIndex: string | null = null;\n try {\n const result = await db.query(\n `SELECT name FROM sqlite_master\n WHERE type = 'index'\n AND tbl_name = '_smrt_dispatch_subscriptions'\n AND name LIKE 'sqlite_autoindex_%'`,\n );\n for (const row of getQueryRows(result)) {\n const indexName = String(row.name);\n const info = await db.query(`PRAGMA index_info(${indexName})`);\n const cols = getQueryRows(info)\n .map((r) => String(r.name))\n .sort();\n if (\n cols.length === 2 &&\n cols[0] === 'signal_type' &&\n cols[1] === 'subscriber'\n ) {\n legacyAutoIndex = indexName;\n break;\n }\n }\n } catch {\n // Unable to introspect → assume no legacy constraint (fresh tables created\n // from the new schema have none).\n legacyAutoIndex = null;\n }\n\n if (!legacyAutoIndex) {\n return;\n }\n\n // Rebuild the table without the inline UNIQUE(signal_type, subscriber).\n // System table is small; this runs once per legacy database.\n await db.query('PRAGMA foreign_keys=OFF');\n try {\n await db.query(`\n CREATE TABLE _smrt_dispatch_subscriptions_new (\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 await db.query(`\n INSERT INTO _smrt_dispatch_subscriptions_new\n (id, signal_type, subscriber, handler, delivery, enabled, tenant_id, created_at, updated_at)\n SELECT id, signal_type, subscriber, handler, delivery, enabled, tenant_id, created_at, updated_at\n FROM _smrt_dispatch_subscriptions\n `);\n await db.query('DROP TABLE _smrt_dispatch_subscriptions');\n await db.query(\n 'ALTER TABLE _smrt_dispatch_subscriptions_new RENAME TO _smrt_dispatch_subscriptions',\n );\n // Recreate the secondary indexes (the rebuild dropped them) plus the\n // tenant-scoped unique index.\n await db.query(\n 'CREATE UNIQUE INDEX IF NOT EXISTS uq_smrt_dispatch_subs_tenant_signal_subscriber ON _smrt_dispatch_subscriptions(tenant_id, signal_type, subscriber)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_subscriber ON _smrt_dispatch_subscriptions(subscriber)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_tenant_id ON _smrt_dispatch_subscriptions(tenant_id)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_signal_type ON _smrt_dispatch_subscriptions(signal_type)',\n );\n await db.query(\n 'CREATE INDEX IF NOT EXISTS idx_smrt_dispatch_subs_enabled ON _smrt_dispatch_subscriptions(enabled)',\n );\n } finally {\n await db.query('PRAGMA foreign_keys=ON');\n }\n}\n\nexport async function ensureDispatchSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_dispatch', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(\n db,\n '_smrt_dispatch',\n 'target_subscriber',\n 'TEXT',\n typeHint,\n );\n await addColumnIfMissing(\n db,\n '_smrt_dispatch',\n 'correlation_id',\n 'TEXT',\n typeHint,\n );\n // Tenant isolation (S5 #1398): dispatches are scoped to the emitting\n // tenant context so a subscriber in tenant A cannot snoop/claim tenant B's\n // dispatches. Nullable so non-tenant (global) dispatches keep tenant_id NULL.\n await addColumnIfMissing(db, '_smrt_dispatch', 'tenant_id', 'TEXT', typeHint);\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_target',\n '_smrt_dispatch',\n 'target_subscriber',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_correlation',\n '_smrt_dispatch',\n 'correlation_id',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_tenant_id',\n '_smrt_dispatch',\n 'tenant_id',\n typeHint,\n );\n}\n\nexport async function ensureDispatchSubscriptionsSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_dispatch_subscriptions', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(\n db,\n '_smrt_dispatch_subscriptions',\n 'delivery',\n \"TEXT NOT NULL DEFAULT 'compete'\",\n typeHint,\n );\n // Tenant isolation (S5 #1398): records the tenant context a subscription was\n // registered in. Nullable so global/non-tenant subscriptions keep it NULL.\n await addColumnIfMissing(\n db,\n '_smrt_dispatch_subscriptions',\n 'tenant_id',\n 'TEXT',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_dispatch_subs_tenant_id',\n '_smrt_dispatch_subscriptions',\n 'tenant_id',\n typeHint,\n );\n // Migrate the subscription natural key to (tenant_id, signal_type, subscriber)\n // so tenants can't clobber each other's subscriptions (S5 #1398).\n await migrateDispatchSubscriptionsIdentity(db, typeHint);\n}\n\nexport async function ensureJobsSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_jobs', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(db, '_smrt_jobs', 'tenant_id', 'TEXT', typeHint);\n await addIndexIfMissing(\n db,\n 'idx_smrt_jobs_tenant_id',\n '_smrt_jobs',\n 'tenant_id',\n typeHint,\n );\n}\n\nexport async function ensureJobEventsSystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n if (!(await tableExists(db, '_smrt_job_events', typeHint))) {\n return;\n }\n\n await addColumnIfMissing(\n db,\n '_smrt_job_events',\n 'tenant_id',\n 'TEXT',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_tenant_id',\n '_smrt_job_events',\n 'tenant_id',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_job_id',\n '_smrt_job_events',\n 'job_id',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_type',\n '_smrt_job_events',\n 'type',\n typeHint,\n );\n await addIndexIfMissing(\n db,\n 'idx_smrt_job_events_created_at',\n '_smrt_job_events',\n 'created_at',\n typeHint,\n );\n}\n\nexport async function ensureLegacySystemTableCompatibility(\n db: DatabaseInterface,\n typeHint?: string,\n): Promise<void> {\n await ensureDispatchSystemTableCompatibility(db, typeHint);\n await ensureDispatchSubscriptionsSystemTableCompatibility(db, typeHint);\n await ensureJobsSystemTableCompatibility(db, typeHint);\n await ensureJobEventsSystemTableCompatibility(db, typeHint);\n}\n"],"mappings":"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"}
|
package/dist/system/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, tableExists } from "./compatibility.js";
|
|
2
|
-
export { ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, tableExists };
|
|
1
|
+
import { assertPostgresSystemTimestampsCurrent, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, getDatabaseEngine, migratePostgresSystemTimestamps, tableExists } from "./compatibility.js";
|
|
2
|
+
export { assertPostgresSystemTimestampsCurrent, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, ensureLegacySystemTableCompatibility, getDatabaseEngine, migratePostgresSystemTimestamps, tableExists };
|
package/dist/system/schema.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* SMRT System Tables Schema
|
|
3
|
-
*
|
|
4
|
-
* System tables use _smrt_ prefix to avoid conflicts with user tables.
|
|
5
|
-
* All system tables are created in the same database as user data.
|
|
6
|
-
*/
|
|
1
|
+
import { DatabaseEngine } from '../schema/ddl/types.js';
|
|
7
2
|
/**
|
|
8
3
|
* Context memory storage
|
|
9
4
|
* Stores remembered context (learned strategies, patterns, selectors) for reuse
|
|
@@ -72,10 +67,14 @@ export declare const CREATE_SMRT_AI_USAGE_TABLE = "\nCREATE TABLE IF NOT EXISTS
|
|
|
72
67
|
* table-level change without a specific row.
|
|
73
68
|
*/
|
|
74
69
|
export declare 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";
|
|
70
|
+
/** PostgreSQL materialization of the portable change-feed table DDL. */
|
|
71
|
+
export declare const CREATE_POSTGRES_SMRT_CHANGES_TABLE: string;
|
|
75
72
|
/** PostgreSQL helper used to isolate best-effort feed appends (#2026). */
|
|
76
73
|
export declare const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME = "_smrt_append_change";
|
|
74
|
+
/** Legacy helper identity used before PostgreSQL Date values became instants. */
|
|
75
|
+
export declare const LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = "_smrt_append_change(text,text,text,text,timestamp without time zone)";
|
|
77
76
|
/** Exact PostgreSQL identity used for catalog lookup of the append helper. */
|
|
78
|
-
export declare const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = "_smrt_append_change(text,text,text,text,timestamp
|
|
77
|
+
export declare const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = "_smrt_append_change(text,text,text,text,timestamp with time zone)";
|
|
79
78
|
/**
|
|
80
79
|
* PostgreSQL-only change-feed append function.
|
|
81
80
|
*
|
|
@@ -86,7 +85,7 @@ export declare const POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = "_smrt_appe
|
|
|
86
85
|
* semicolons, so callers must execute it whole rather than adding it to
|
|
87
86
|
* {@link ALL_SYSTEM_TABLES}, whose portable DDL entries are semicolon-split.
|
|
88
87
|
*/
|
|
89
|
-
export declare const CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = "\nCREATE OR REPLACE FUNCTION _smrt_append_change(\n p_table_name TEXT,\n p_row_id TEXT,\n p_operation TEXT,\n p_tenant_id TEXT,\n p_created_at
|
|
88
|
+
export declare const CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION = "\nCREATE OR REPLACE FUNCTION _smrt_append_change(\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$\nDECLARE\n v_seq BIGINT;\n v_error_code TEXT;\n v_error_message TEXT;\nBEGIN\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";
|
|
90
89
|
/**
|
|
91
90
|
* Serialize PostgreSQL helper replacement inside one server-side statement.
|
|
92
91
|
*
|
|
@@ -124,8 +123,17 @@ export declare const CREATE_SMRT_BACKFILLS_TABLE = "\nCREATE TABLE IF NOT EXISTS
|
|
|
124
123
|
* All system table creation statements
|
|
125
124
|
*/
|
|
126
125
|
export declare const ALL_SYSTEM_TABLES: string[];
|
|
126
|
+
/**
|
|
127
|
+
* Materialize the portable system-table schema for a target engine.
|
|
128
|
+
*
|
|
129
|
+
* PostgreSQL must use TIMESTAMPTZ because every system Date is an instant.
|
|
130
|
+
* Other engines keep the established portable TIMESTAMP declarations.
|
|
131
|
+
*/
|
|
132
|
+
export declare function getSystemTableDDL(engine: DatabaseEngine): string[];
|
|
133
|
+
/** Materialize one portable system-table statement for a target engine. */
|
|
134
|
+
export declare function getSystemTableDDLForEngine(ddl: string, engine: DatabaseEngine): string;
|
|
127
135
|
/**
|
|
128
136
|
* Current SMRT system schema version
|
|
129
137
|
*/
|
|
130
|
-
export declare const SMRT_SCHEMA_VERSION = "1.
|
|
138
|
+
export declare const SMRT_SCHEMA_VERSION = "1.9.0";
|
|
131
139
|
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/system/schema.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;GAGG;AACH,eAAO,MAAM,0BAA0B,+5BA+BtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,4MAQxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mCAAmC,o0BA4B/C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,6OAUtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,sgBAoBrC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,u0BA2BxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,kmCAwCtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,wCAAwC,69CAkCpD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,swBA4BtC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,weAkBrC,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,yCAAyC,wBAAwB,CAAC;AAE/E,8EAA8E;AAC9E,eAAO,MAAM,6CAA6C,
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/system/schema.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D;;;GAGG;AACH,eAAO,MAAM,0BAA0B,+5BA+BtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,4MAQxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mCAAmC,o0BA4B/C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,6OAUtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,sgBAoBrC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,u0BA2BxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,kmCAwCtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,wCAAwC,69CAkCpD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,0BAA0B,swBA4BtC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,weAkBrC,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,kCAAkC,QACqB,CAAC;AAErE,0EAA0E;AAC1E,eAAO,MAAM,yCAAyC,wBAAwB,CAAC;AAE/E,iFAAiF;AACjF,eAAO,MAAM,oDAAoD,yEAAkG,CAAC;AAEpK,8EAA8E;AAC9E,eAAO,MAAM,6CAA6C,sEAA+F,CAAC;AAE1J;;;;;;;;;GASG;AACH,eAAO,MAAM,2CAA2C,ylCAiDvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4CAA4C,QAaxD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2CAA2C,QAevD,CAAC;AAaF;;;;;;GAMG;AACH,eAAO,MAAM,kCAAkC,QAgB9C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,gLAOvC,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,UAY7B,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,EAAE,CAIlE;AAED,2EAA2E;AAC3E,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,cAAc,GACrB,MAAM,CAIR;AAED;;GAEG;AACH,eAAO,MAAM,mBAAmB,UAAU,CAAC"}
|
package/dist/system/schema.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
//#region src/system/schema.ts
|
|
2
2
|
/**
|
|
3
|
-
* SMRT System Tables Schema
|
|
4
|
-
*
|
|
5
|
-
* System tables use _smrt_ prefix to avoid conflicts with user tables.
|
|
6
|
-
* All system tables are created in the same database as user data.
|
|
7
|
-
*/
|
|
8
|
-
/**
|
|
9
3
|
* Context memory storage
|
|
10
4
|
* Stores remembered context (learned strategies, patterns, selectors) for reuse
|
|
11
5
|
*/
|
|
@@ -317,10 +311,14 @@ CREATE INDEX IF NOT EXISTS idx_smrt_changes_tenant_seq
|
|
|
317
311
|
CREATE INDEX IF NOT EXISTS idx_smrt_changes_created_at
|
|
318
312
|
ON _smrt_changes(created_at);
|
|
319
313
|
`;
|
|
314
|
+
/** PostgreSQL materialization of the portable change-feed table DDL. */
|
|
315
|
+
var CREATE_POSTGRES_SMRT_CHANGES_TABLE = CREATE_SMRT_CHANGES_TABLE.replace(/\bTIMESTAMP\b/g, "TIMESTAMPTZ");
|
|
320
316
|
/** PostgreSQL helper used to isolate best-effort feed appends (#2026). */
|
|
321
317
|
var POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME = "_smrt_append_change";
|
|
318
|
+
/** Legacy helper identity used before PostgreSQL Date values became instants. */
|
|
319
|
+
var LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp without time zone)`;
|
|
322
320
|
/** Exact PostgreSQL identity used for catalog lookup of the append helper. */
|
|
323
|
-
var POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp
|
|
321
|
+
var POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY = `${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(text,text,text,text,timestamp with time zone)`;
|
|
324
322
|
/**
|
|
325
323
|
* PostgreSQL-only change-feed append function.
|
|
326
324
|
*
|
|
@@ -337,7 +335,7 @@ CREATE OR REPLACE FUNCTION ${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME}(
|
|
|
337
335
|
p_row_id TEXT,
|
|
338
336
|
p_operation TEXT,
|
|
339
337
|
p_tenant_id TEXT,
|
|
340
|
-
p_created_at
|
|
338
|
+
p_created_at TIMESTAMPTZ
|
|
341
339
|
)
|
|
342
340
|
RETURNS TABLE(
|
|
343
341
|
allocated_seq BIGINT,
|
|
@@ -395,6 +393,7 @@ BEGIN
|
|
|
395
393
|
hashtext('smrt'),
|
|
396
394
|
hashtext('system-tables')
|
|
397
395
|
);
|
|
396
|
+
DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};
|
|
398
397
|
EXECUTE $smrt_change_feed_ddl$
|
|
399
398
|
${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}
|
|
400
399
|
$smrt_change_feed_ddl$;
|
|
@@ -415,6 +414,7 @@ BEGIN
|
|
|
415
414
|
hashtext('smrt'),
|
|
416
415
|
hashtext('system-tables')
|
|
417
416
|
);
|
|
417
|
+
DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};
|
|
418
418
|
IF to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') IS NULL THEN
|
|
419
419
|
EXECUTE $smrt_change_feed_ddl$
|
|
420
420
|
${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}
|
|
@@ -437,9 +437,10 @@ BEGIN
|
|
|
437
437
|
hashtext('smrt'),
|
|
438
438
|
hashtext('system-tables')
|
|
439
439
|
);
|
|
440
|
-
${
|
|
440
|
+
${CREATE_POSTGRES_SMRT_CHANGES_TABLE.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0).map((statement) => `EXECUTE $smrt_change_feed_schema_ddl$
|
|
441
441
|
${statement}
|
|
442
442
|
$smrt_change_feed_schema_ddl$;`).join("\n")}
|
|
443
|
+
DROP FUNCTION IF EXISTS ${LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY};
|
|
443
444
|
IF to_regprocedure('${POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY}') IS NULL THEN
|
|
444
445
|
EXECUTE $smrt_change_feed_ddl$
|
|
445
446
|
${CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION.trim()}
|
|
@@ -481,10 +482,23 @@ var ALL_SYSTEM_TABLES = [
|
|
|
481
482
|
CREATE_SMRT_CHANGES_TABLE
|
|
482
483
|
];
|
|
483
484
|
/**
|
|
485
|
+
* Materialize the portable system-table schema for a target engine.
|
|
486
|
+
*
|
|
487
|
+
* PostgreSQL must use TIMESTAMPTZ because every system Date is an instant.
|
|
488
|
+
* Other engines keep the established portable TIMESTAMP declarations.
|
|
489
|
+
*/
|
|
490
|
+
function getSystemTableDDL(engine) {
|
|
491
|
+
return ALL_SYSTEM_TABLES.map((ddl) => getSystemTableDDLForEngine(ddl, engine));
|
|
492
|
+
}
|
|
493
|
+
/** Materialize one portable system-table statement for a target engine. */
|
|
494
|
+
function getSystemTableDDLForEngine(ddl, engine) {
|
|
495
|
+
return engine === "postgres" ? ddl.replace(/\bTIMESTAMP\b/g, "TIMESTAMPTZ") : ddl;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
484
498
|
* Current SMRT system schema version
|
|
485
499
|
*/
|
|
486
|
-
var SMRT_SCHEMA_VERSION = "1.
|
|
500
|
+
var SMRT_SCHEMA_VERSION = "1.9.0";
|
|
487
501
|
//#endregion
|
|
488
|
-
export { ALL_SYSTEM_TABLES, CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, 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_REGISTRY_TABLE, CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE, CREATE_SMRT_SIGNALS_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, SMRT_SCHEMA_VERSION };
|
|
502
|
+
export { ALL_SYSTEM_TABLES, CREATE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, 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_REGISTRY_TABLE, CREATE_SMRT_SCHEMA_MIGRATIONS_TABLE, CREATE_SMRT_SIGNALS_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, LEGACY_POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, SMRT_SCHEMA_VERSION, getSystemTableDDL, getSystemTableDDLForEngine };
|
|
489
503
|
|
|
490
504
|
//# sourceMappingURL=schema.js.map
|