@cosmicdrift/kumiko-framework 0.362.0 → 0.363.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/api/auth-middleware.js +7 -5
  2. package/dist/api/redis-sse-broker.js +10 -8
  3. package/dist/api/routes.js +2 -2
  4. package/dist/bun-db/query.d.ts +6 -4
  5. package/dist/bun-db/query.js +66 -92
  6. package/dist/crypto/kek-source.js +5 -3
  7. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  8. package/dist/crypto/pii-field-encryption.js +2 -2
  9. package/dist/db/apply-entity-event.js +2 -2
  10. package/dist/db/cursor.js +2 -1
  11. package/dist/db/dialect.d.ts +1 -0
  12. package/dist/db/dialect.js +28 -13
  13. package/dist/db/entity-table-from-registry.js +1 -1
  14. package/dist/db/entity-table-meta.js +5 -13
  15. package/dist/db/event-store-executor-context.js +6 -6
  16. package/dist/db/event-store-executor-read.js +9 -9
  17. package/dist/db/migrate-generator.js +5 -3
  18. package/dist/db/migrate-runner.js +31 -35
  19. package/dist/db/parent-ref-clause.js +2 -2
  20. package/dist/db/queries/backfill-pii.js +11 -9
  21. package/dist/db/queries/es-ops.js +1 -1
  22. package/dist/db/queries/event-consumer.d.ts +6 -6
  23. package/dist/db/queries/event-consumer.js +17 -17
  24. package/dist/db/queries/event-store-admin.js +6 -6
  25. package/dist/db/queries/event-store-transfer.js +2 -2
  26. package/dist/db/queries/event-store.d.ts +1 -1
  27. package/dist/db/queries/event-store.js +2 -2
  28. package/dist/db/queries/pii-backfill-state.js +2 -2
  29. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  30. package/dist/db/queries/raw-sql.js +1 -1
  31. package/dist/db/queries/test-stack.js +1 -1
  32. package/dist/db/reference-data.js +3 -5
  33. package/dist/db/table-builder.js +6 -4
  34. package/dist/db/table-introspection.d.ts +16 -0
  35. package/dist/db/table-introspection.js +76 -0
  36. package/dist/db/tenant-db.d.ts +3 -2
  37. package/dist/db/tenant-db.js +4 -10
  38. package/dist/derivatives/variant-key.js +2 -1
  39. package/dist/engine/boot-validator/entity-list-screens.js +1 -1
  40. package/dist/engine/boot-validator/pii-retention.js +1 -1
  41. package/dist/engine/boot-validator/screens.js +2 -1
  42. package/dist/engine/changeset-changes.js +3 -3
  43. package/dist/engine/define-handler.js +3 -6
  44. package/dist/engine/embedded-derived.js +10 -6
  45. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  46. package/dist/engine/factories.js +5 -6
  47. package/dist/engine/feature-ast/extractors/events.js +43 -33
  48. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  49. package/dist/engine/feature-ast/patcher.js +3 -3
  50. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
  51. package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
  52. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  53. package/dist/engine/feature-ast/render.js +6 -3
  54. package/dist/engine/feature-changelog.d.ts +2 -1
  55. package/dist/engine/feature-changelog.js +8 -6
  56. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  57. package/dist/engine/feature-config-events-jobs.js +20 -5
  58. package/dist/engine/feature-ui-extensions.js +2 -3
  59. package/dist/engine/index.d.ts +2 -1
  60. package/dist/engine/index.js +1 -0
  61. package/dist/engine/ownership.js +7 -20
  62. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  63. package/dist/engine/registry-ingest.d.ts +2 -1
  64. package/dist/engine/registry-ingest.js +85 -2
  65. package/dist/engine/registry-validate.d.ts +1 -0
  66. package/dist/engine/registry-validate.js +38 -8
  67. package/dist/engine/registry.js +2 -1
  68. package/dist/engine/schema-builder.js +1 -3
  69. package/dist/engine/soft-delete-cleanup.js +2 -1
  70. package/dist/engine/steps/call-feature.js +1 -3
  71. package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
  72. package/dist/engine/template-variables.d.ts +29 -0
  73. package/dist/engine/template-variables.js +77 -0
  74. package/dist/engine/tier-resolver-extension.js +2 -3
  75. package/dist/engine/types/index.d.ts +2 -2
  76. package/dist/engine/validate-projection-allowlist.js +12 -13
  77. package/dist/engine/where-rule-lint.js +8 -7
  78. package/dist/errors/reasons.d.ts +1 -0
  79. package/dist/errors/reasons.js +2 -0
  80. package/dist/files/file-routes.js +1 -1
  81. package/dist/files/local-provider.js +2 -1
  82. package/dist/i18n/index.d.ts +1 -1
  83. package/dist/i18n/index.js +1 -1
  84. package/dist/i18n/mail-registry.d.ts +2 -0
  85. package/dist/i18n/mail-registry.js +7 -0
  86. package/dist/jobs/job-runner.js +5 -5
  87. package/dist/pipeline/dispatch-query.js +2 -3
  88. package/dist/pipeline/dispatch-shared.js +45 -14
  89. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  90. package/dist/pipeline/index.d.ts +1 -0
  91. package/dist/pipeline/index.js +1 -0
  92. package/dist/pipeline/member-read-only-transaction.js +1 -1
  93. package/dist/pipeline/notify-scope.d.ts +13 -0
  94. package/dist/pipeline/notify-scope.js +32 -0
  95. package/dist/pipeline/system-hooks.js +2 -1
  96. package/dist/pipeline/system-identity-switch.js +2 -2
  97. package/dist/search/meilisearch-adapter.js +3 -4
  98. package/dist/secrets/stored-envelope.js +7 -7
  99. package/dist/stack/push-entity-projection-tables.js +2 -3
  100. package/dist/stack/table-helpers.js +10 -26
  101. package/dist/stack/test-stack.js +2 -5
  102. package/dist/testing/multipart-helper.d.ts +0 -6
  103. package/dist/testing/multipart-helper.js +5 -5
  104. package/dist/testing/without-ambient-temporal.js +4 -4
  105. package/dist/utils/is-plain-object.d.ts +1 -2
  106. package/dist/utils/is-plain-object.js +1 -4
  107. package/package.json +4 -4
  108. package/src/changes.json +44 -0
@@ -5,14 +5,14 @@ import { asRawClient } from "../query.js";
5
5
  // unassigned one. Both travel as strings — bigint/xid8 doesn't round-trip
6
6
  // through the driver as a JS number.
7
7
  export async function selectSnapshotXmin(db) {
8
- const rows = (await asRawClient(db).unsafe(`SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`));
8
+ const rows = await asRawClient(db).unsafe(`SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`);
9
9
  const xmin = rows[0]?.xmin;
10
10
  if (xmin === undefined)
11
11
  throw new Error("selectSnapshotXmin: no row returned");
12
12
  return xmin;
13
13
  }
14
14
  export async function selectSnapshotXmax(db) {
15
- const rows = (await asRawClient(db).unsafe(`SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`));
15
+ const rows = await asRawClient(db).unsafe(`SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`);
16
16
  const xmax = rows[0]?.xmax;
17
17
  if (xmax === undefined)
18
18
  throw new Error("selectSnapshotXmax: no row returned");
@@ -36,7 +36,7 @@ export async function lockEventConsumersShareMode(db) {
36
36
  // is still in flight, which pg_snapshot_xip being empty rules out, so the window
37
37
  // scan is skipped then.
38
38
  export async function selectEventIdHorizonWithMissingRanges(db) {
39
- const rows = (await asRawClient(db).unsafe(`WITH a AS (
39
+ const rows = await asRawClient(db).unsafe(`WITH a AS (
40
40
  SELECT "last_processed_event_id" AS cursor, "pending_gaps" AS gaps
41
41
  FROM "kumiko_event_consumers"
42
42
  ORDER BY "last_processed_event_id" DESC
@@ -64,7 +64,7 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
64
64
  r.t::text AS gap_to,
65
65
  r.x AS gap_xmax
66
66
  FROM b LEFT JOIN r ON true
67
- ORDER BY r.f`));
67
+ ORDER BY r.f`);
68
68
  const first = rows[0];
69
69
  if (first === undefined)
70
70
  throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
@@ -89,7 +89,7 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
89
89
  // subquery only ever affects the row this statement inserts.
90
90
  export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "beginning") {
91
91
  if (startFrom === "now") {
92
- const existing = (await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`, [name, instanceId]));
92
+ const existing = await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`, [name, instanceId]);
93
93
  // skip: already registered — an existing cursor is never re-seeded.
94
94
  if (existing.length > 0)
95
95
  return;
@@ -116,18 +116,18 @@ export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "
116
116
  // cycle. The
117
117
  // type-only import keeps the parameter pinned to the "dead" literal.
118
118
  export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
119
- const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
119
+ const rows = await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
120
120
  FROM "kumiko_event_consumers" c
121
121
  WHERE ("name", "instance_id") = ANY (SELECT * FROM unnest($1::text[], $2::text[]))
122
122
  AND "status" != $3
123
123
  AND "pending_gaps" = '[]'::jsonb
124
124
  AND NOT EXISTS (
125
125
  SELECT 1 FROM "kumiko_events" e WHERE e."id" > c."last_processed_event_id"
126
- )`, [names, instanceIds, deadStatus]));
126
+ )`, [names, instanceIds, deadStatus]);
127
127
  return rows.map((r) => ({ name: r.name, instanceId: r.instance_id }));
128
128
  }
129
129
  export async function selectConsumerForUpdateSkipLocked(db, name, instanceId) {
130
- const rows = (await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE SKIP LOCKED`, [name, instanceId]));
130
+ const rows = await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE SKIP LOCKED`, [name, instanceId]);
131
131
  return rows[0];
132
132
  }
133
133
  export async function markConsumerProcessing(db, name, instanceId) {
@@ -194,15 +194,15 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
194
194
  ]);
195
195
  }
196
196
  export async function updateConsumerStatusReturning(db, name, instanceId, status) {
197
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
197
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
198
198
  WHERE "name" = $2 AND "instance_id" = $3
199
- RETURNING *`, [status, name, instanceId]));
199
+ RETURNING *`, [status, name, instanceId]);
200
200
  return rows[0];
201
201
  }
202
202
  export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
203
203
  // Full list, including ids skipped over between the old cursor and eventId that were not visible yet.
204
204
  pendingGaps) {
205
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
205
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
206
206
  "last_processed_event_id" = $1,
207
207
  "status" = 'idle',
208
208
  "attempts" = 0,
@@ -213,7 +213,7 @@ pendingGaps) {
213
213
  "pending_gaps" = $4::text::jsonb,
214
214
  "updated_at" = now()
215
215
  WHERE "name" = $2 AND "instance_id" = $3
216
- RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]));
216
+ RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]);
217
217
  return rows[0];
218
218
  }
219
219
  export async function resetConsumerForMspRebuild(db, name, instanceId) {
@@ -229,7 +229,7 @@ export async function resetConsumerForMspRebuild(db, name, instanceId) {
229
229
  "updated_at" = now()`, [name, instanceId]);
230
230
  }
231
231
  export async function selectConsumerForUpdate(db, name, instanceId) {
232
- const rows = (await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`, [name, instanceId]));
232
+ const rows = await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`, [name, instanceId]);
233
233
  return rows[0];
234
234
  }
235
235
  export async function updateConsumerRebuildCursor(db, name, instanceId, lastProcessedEventId, pendingGaps) {
@@ -254,14 +254,14 @@ export async function markConsumerRebuildFailed(db, name, instanceId, errorMessa
254
254
  // so the caller can enforce a lifetime cap on automatic revivals; manual
255
255
  // restartConsumer()/enableConsumer() reset it back to 0.
256
256
  export async function rearmDeadConsumer(db, name, instanceId) {
257
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
257
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
258
258
  "status" = 'idle',
259
259
  "attempts" = 0,
260
260
  "last_error" = NULL,
261
261
  "rearm_count" = "rearm_count" + 1,
262
262
  "updated_at" = now()
263
263
  WHERE "name" = $1 AND "instance_id" = $2
264
- RETURNING *`, [name, instanceId]));
264
+ RETURNING *`, [name, instanceId]);
265
265
  return rows[0];
266
266
  }
267
267
  // skipPoisonEvent's pending-gap branch: the poison is a pending id below the
@@ -269,7 +269,7 @@ export async function rearmDeadConsumer(db, name, instanceId) {
269
269
  // last_processed_event_id (advancing it here would be a regression — the
270
270
  // cursor already sits above this id).
271
271
  export async function removePendingGapReturning(db, name, instanceId, newPendingGaps) {
272
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
272
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
273
273
  "pending_gaps" = $1::text::jsonb,
274
274
  "status" = 'idle',
275
275
  "attempts" = 0,
@@ -278,6 +278,6 @@ export async function removePendingGapReturning(db, name, instanceId, newPending
278
278
  "last_failed_event_id" = NULL,
279
279
  "updated_at" = now()
280
280
  WHERE "name" = $2 AND "instance_id" = $3
281
- RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]));
281
+ RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]);
282
282
  return rows[0];
283
283
  }
@@ -18,7 +18,7 @@ export async function insertRawFirstEvent(db, params) {
18
18
  ]);
19
19
  }
20
20
  export async function insertRawSubsequentEvent(db, params) {
21
- const rows = (await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
21
+ const rows = await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
22
22
  aggregate_id, aggregate_type, tenant_id, version,
23
23
  type, event_version, payload, metadata, created_at, created_by
24
24
  )
@@ -41,7 +41,7 @@ export async function insertRawSubsequentEvent(db, params) {
41
41
  params.createdAt,
42
42
  params.createdBy,
43
43
  params.expectedVersion,
44
- ]));
44
+ ]);
45
45
  return rows.length > 0;
46
46
  }
47
47
  export async function insertRawEventBatch(db, sqlValues, params) {
@@ -52,18 +52,18 @@ export async function insertRawEventBatch(db, sqlValues, params) {
52
52
  VALUES ${sqlValues}`, params);
53
53
  }
54
54
  export async function eventPredecessorExists(db, aggregateId, tenantId, version) {
55
- const rows = (await asRawClient(db).unsafe(`SELECT EXISTS(
55
+ const rows = await asRawClient(db).unsafe(`SELECT EXISTS(
56
56
  SELECT 1 FROM "kumiko_events"
57
57
  WHERE aggregate_id = $1::uuid
58
58
  AND tenant_id = $2::uuid
59
59
  AND version = $3
60
- ) AS present`, [aggregateId, tenantId, version]));
60
+ ) AS present`, [aggregateId, tenantId, version]);
61
61
  return rows[0]?.present === true;
62
62
  }
63
63
  export async function findExistingEventVersion(db, sqlInClause, params) {
64
- const rows = (await asRawClient(db).unsafe(`SELECT aggregate_id, version FROM "kumiko_events"
64
+ const rows = await asRawClient(db).unsafe(`SELECT aggregate_id, version FROM "kumiko_events"
65
65
  WHERE (tenant_id, aggregate_id, version) IN (${sqlInClause})
66
- LIMIT 1`, params));
66
+ LIMIT 1`, params);
67
67
  const row = rows[0];
68
68
  if (!row)
69
69
  return undefined;
@@ -7,9 +7,9 @@ import { asRawClient } from "../query.js";
7
7
  // Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
8
8
  // per-event-row hits in-process instead of relying on invalid SQL.
9
9
  export async function transferEventRows(db, params) {
10
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
10
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
11
11
  `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
12
- `RETURNING "aggregate_id" AS id`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds]));
12
+ `RETURNING "aggregate_id" AS id`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds]);
13
13
  return [...new Set(rows.map((row) => row.id))];
14
14
  }
15
15
  // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
@@ -56,5 +56,5 @@ export type PendingIdRange = {
56
56
  readonly from: bigint;
57
57
  readonly to: bigint;
58
58
  };
59
- export declare function selectPendingAndNewEventRows(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<Record<string, unknown>>>;
59
+ export declare function selectPendingAndNewEventRows<TRow = Record<string, unknown>>(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<TRow>>;
60
60
  export declare function selectSmallestVisibleIdInRanges(db: AnyDb, pendingRanges: readonly PendingIdRange[]): Promise<bigint | null>;
@@ -139,7 +139,7 @@ function duplicateIdempotencyKeyErrorOr(e) {
139
139
  }
140
140
  /** INSERT … SELECT … WHERE EXISTS predecessor — stays raw (typed builder can't express this). */
141
141
  export async function insertSubsequentEventRow(db, params) {
142
- const rows = (await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
142
+ const rows = await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
143
143
  aggregate_id, aggregate_type, tenant_id, version,
144
144
  type, event_version, payload, metadata, created_by, created_at
145
145
  )
@@ -164,7 +164,7 @@ export async function insertSubsequentEventRow(db, params) {
164
164
  params.createdBy,
165
165
  params.expectedVersion,
166
166
  params.createdAt ?? null,
167
- ]));
167
+ ]);
168
168
  return rows[0];
169
169
  }
170
170
  export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
@@ -1,8 +1,8 @@
1
1
  import { asRawClient } from "../query.js";
2
2
  export async function readPiiBackfillState(db, fingerprint) {
3
- const rows = (await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
3
+ const rows = await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
4
4
  "failed_event_ids"::text[] AS "failedEventIds"
5
- FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]));
5
+ FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]);
6
6
  return rows[0] ?? null;
7
7
  }
8
8
  export async function persistPiiBackfillState(db, fingerprint, update) {
@@ -1,6 +1,6 @@
1
1
  import type { AnyDb } from "../query.js";
2
2
  export declare function markProjectionRebuilding(db: AnyDb, projectionName: string): Promise<void>;
3
- export declare function selectEventsForProjectionRebuildBatch(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<Record<string, unknown>>>;
3
+ export declare function selectEventsForProjectionRebuildBatch<TRow = Record<string, unknown>>(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<TRow>>;
4
4
  export declare function countSubscribedEvents(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[]): Promise<bigint>;
5
5
  export declare function finalizeProjectionRebuild(db: AnyDb, projectionName: string, lastProcessedEventId: bigint): Promise<void>;
6
6
  export declare function markProjectionRebuildFailed(db: AnyDb, projectionName: string, errorMessage: string): Promise<void>;
@@ -3,7 +3,7 @@ import { asRawClient, unsafeReadRetrying } from "../query.js";
3
3
  * retrying an ambiguous write risks double-apply (#1358). Prefer {@link executeRawQueryRead}
4
4
  * for SELECT-only paths. */
5
5
  export async function executeRawQuery(db, sqlText, params = []) {
6
- return (await asRawClient(db).unsafe(sqlText, params));
6
+ return await asRawClient(db).unsafe(sqlText, params);
7
7
  }
8
8
  /** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
9
9
  * INSERT/UPDATE/DELETE — retry re-executes the statement. */
@@ -9,7 +9,7 @@ export async function truncateTablesRestartIdentity(db, tableNames) {
9
9
  await asRawClient(db).unsafe(`TRUNCATE ${quoted} RESTART IDENTITY CASCADE`);
10
10
  }
11
11
  export async function databaseExists(db, dbName) {
12
- const rows = (await asRawClient(db).unsafe(`SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists`, [dbName]));
12
+ const rows = await asRawClient(db).unsafe(`SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists`, [dbName]);
13
13
  return rows[0]?.exists === true;
14
14
  }
15
15
  export async function createDatabase(db, dbName) {
@@ -1,11 +1,9 @@
1
- import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { fetchOne, insertOne, updateMany } from "../db/query.js";
3
2
  import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
3
+ import { tableColumnsOf } from "./table-introspection.js";
4
4
  function hasColumn(table, field) {
5
- const cols = table[KUMIKO_COLUMNS_SYMBOL];
6
- if (typeof cols !== "object" || cols === null)
7
- return false;
8
- return field in cols;
5
+ const cols = tableColumnsOf(table);
6
+ return cols !== undefined && field in cols;
9
7
  }
10
8
  /**
11
9
  * Seed reference data at boot time.
@@ -1,15 +1,16 @@
1
1
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { toSnakeCase } from "../utils/case.js";
3
3
  import { assertUnreachable } from "../utils/index.js";
4
+ import { columnHandleName } from "./table-introspection.js";
4
5
  export { toSnakeCase } from "../utils/case.js";
5
- import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
6
+ import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, isSqlExpression, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
6
7
  // Partial-index predicate for soft-delete entities; table-builder and
7
8
  // entity-table-meta must render identical index definitions.
8
9
  export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
9
10
  // Shared by the CRUD executor and the parent-ref read-gate — a field's
10
11
  // declared column name if it has one, else its snake_case default.
11
12
  export function physicalColumnName(table, field) {
12
- return table[field]?.name ?? toSnakeCase(field);
13
+ return columnHandleName(table, field) ?? toSnakeCase(field);
13
14
  }
14
15
  // Returns column(s) for a field. Most fields return a single entry,
15
16
  // money returns two (amount + currency).
@@ -281,7 +282,7 @@ export function declareGlobalTenancy(table) {
281
282
  ...meta,
282
283
  tenancy: "global",
283
284
  };
284
- // @cast-boundary type-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
285
+ // @cast-boundary phantom-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
285
286
  return table;
286
287
  }
287
288
  export function buildEntityTable(entityName, entity, options) {
@@ -377,7 +378,8 @@ export function buildEntityTable(entityName, entity, options) {
377
378
  // entity.indexes[].where is now a SqlExpression (was drizzle SQL).
378
379
  // Pass through to the IndexBuilderWithCols.where()-API.
379
380
  if (def.where !== undefined) {
380
- chain = chain.where(def.where);
381
+ if (isSqlExpression(def.where))
382
+ chain = chain.where(def.where);
381
383
  }
382
384
  else if (def.unique === true && entity.softDelete === true) {
383
385
  // Soft-deleted rows keep their value, so a full unique index would
@@ -0,0 +1,16 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
+ export declare function isEntityTableMeta(value: unknown): value is EntityTableMeta;
3
+ export declare function asEntityTableMeta(table: unknown): EntityTableMeta | undefined;
4
+ export declare function requireEntityTableMeta(table: unknown, tableName: string): EntityTableMeta;
5
+ /** Physical table name from the unshadowable name symbol; undefined when absent. */
6
+ export declare function schemaTableName(table: unknown): string | undefined;
7
+ /** Name symbol first, then the canonical meta; "<unknown>" when neither resolves. */
8
+ export declare function tableNameOf(table: unknown): string;
9
+ /** Column-handle map stamped by table(); undefined for tables without one. */
10
+ export declare function tableColumnsOf(table: unknown): Record<string, unknown> | undefined;
11
+ /** SQL name carried by the enumerable column handle `table[field]`; undefined when the field has none. */
12
+ export declare function columnHandleName(table: unknown, field: string): string | undefined;
13
+ /** True when `table[field]` is defined, i.e. the table exposes a column handle (or plain value) for the field. */
14
+ export declare function hasFieldProperty(table: unknown, field: string): boolean;
15
+ /** SQL column name of a JS field via the column-handle map; undefined when not mapped. */
16
+ export declare function columnSqlNameOf(table: unknown, field: string): string | undefined;
@@ -0,0 +1,76 @@
1
+ import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ // table() spreads column handles onto the table object as enumerable props, so
4
+ // an entity field named `source`/`columns`/`tableName`/… shadows the matching
5
+ // meta key. The symbol-keyed slots are the only collision-safe source; plain
6
+ // metas (deriveEntityTableMeta / defineUnmanagedTable) carry no handle spread
7
+ // and are unshadowable by their structural shape.
8
+ export function isEntityTableMeta(value) {
9
+ return (typeof value === "object" &&
10
+ value !== null &&
11
+ "tableName" in value &&
12
+ typeof value.tableName === "string" &&
13
+ "columns" in value &&
14
+ Array.isArray(value.columns) &&
15
+ "indexes" in value &&
16
+ Array.isArray(value.indexes) &&
17
+ "source" in value &&
18
+ (value.source === "managed" || value.source === "unmanaged"));
19
+ }
20
+ export function asEntityTableMeta(table) {
21
+ if (typeof table !== "object" || table === null)
22
+ return undefined;
23
+ if (KUMIKO_META_SYMBOL in table && isEntityTableMeta(table[KUMIKO_META_SYMBOL])) {
24
+ return table[KUMIKO_META_SYMBOL];
25
+ }
26
+ return isEntityTableMeta(table) ? table : undefined;
27
+ }
28
+ export function requireEntityTableMeta(table, tableName) {
29
+ const meta = asEntityTableMeta(table);
30
+ if (!meta) {
31
+ throw new Error(`${tableName}: table carries no EntityTableMeta — built via table()?`);
32
+ }
33
+ return meta;
34
+ }
35
+ /** Physical table name from the unshadowable name symbol; undefined when absent. */
36
+ export function schemaTableName(table) {
37
+ if (typeof table !== "object" || table === null || !(KUMIKO_NAME_SYMBOL in table)) {
38
+ return undefined;
39
+ }
40
+ const name = table[KUMIKO_NAME_SYMBOL];
41
+ return typeof name === "string" ? name : undefined;
42
+ }
43
+ /** Name symbol first, then the canonical meta; "<unknown>" when neither resolves. */
44
+ export function tableNameOf(table) {
45
+ return schemaTableName(table) ?? asEntityTableMeta(table)?.tableName ?? "<unknown>";
46
+ }
47
+ /** Column-handle map stamped by table(); undefined for tables without one. */
48
+ export function tableColumnsOf(table) {
49
+ if (typeof table !== "object" || table === null || !(KUMIKO_COLUMNS_SYMBOL in table)) {
50
+ return undefined;
51
+ }
52
+ const columns = table[KUMIKO_COLUMNS_SYMBOL];
53
+ return isPlainObject(columns) ? columns : undefined;
54
+ }
55
+ /** SQL name carried by the enumerable column handle `table[field]`; undefined when the field has none. */
56
+ export function columnHandleName(table, field) {
57
+ if (!isPlainObject(table))
58
+ return undefined;
59
+ const handle = table[field];
60
+ if (!isPlainObject(handle))
61
+ return undefined;
62
+ const name = handle["name"];
63
+ return typeof name === "string" ? name : undefined;
64
+ }
65
+ /** True when `table[field]` is defined, i.e. the table exposes a column handle (or plain value) for the field. */
66
+ export function hasFieldProperty(table, field) {
67
+ return isPlainObject(table) && table[field] !== undefined;
68
+ }
69
+ /** SQL column name of a JS field via the column-handle map; undefined when not mapped. */
70
+ export function columnSqlNameOf(table, field) {
71
+ const handle = tableColumnsOf(table)?.[field];
72
+ if (!isPlainObject(handle))
73
+ return undefined;
74
+ const name = handle["name"];
75
+ return typeof name === "string" ? name : undefined;
76
+ }
@@ -1,6 +1,6 @@
1
1
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
2
  import type { EscapeHatchDeclaration, EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
3
- import { type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
3
+ import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
4
4
  import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
5
5
  import { asRawClient } from "../db/query.js";
6
6
  import type { EntityDefinition } from "../engine/types/fields.js";
@@ -8,6 +8,7 @@ import { type TenantId } from "../engine/types/identifiers.js";
8
8
  import type { Registry } from "../engine/types/index.js";
9
9
  import { type Meter, type Tracer } from "../observability/index.js";
10
10
  import type { DbRunner } from "./connection.js";
11
+ import { tableNameOf } from "./table-introspection.js";
11
12
  type Table = SchemaTable;
12
13
  export { SYSTEM_SCOPE_CHECK_BRAND, type TenantDb, type TenantDbMode, type UncheckedSystemDb, } from "@cosmicdrift/kumiko-types/tenant-db-types";
13
14
  export declare function assertPersonalDataWrite(db: TenantDb, tableName: string, keys: readonly string[], entity: EntityDefinition): void;
@@ -16,7 +17,7 @@ export declare function withSystemDbUnsafeRawGrant(systemDb: UncheckedSystemDb,
16
17
  export declare function createUncheckedSystemDb(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
17
18
  export declare function createSystemDbView(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
18
19
  export declare function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[];
19
- export declare function tableNameOf(table: Table | EntityTableMeta): string;
20
+ export { tableNameOf };
20
21
  export declare function hasTenantColumn(table: Table | EntityTableMeta): boolean;
21
22
  export type TenantDbGrants = {
22
23
  readonly globalWrites?: EscapeHatchDeclaration;
@@ -1,10 +1,10 @@
1
- import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
2
  import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
4
3
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
4
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
6
5
  import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
7
6
  import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
7
+ import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
8
8
  import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
9
9
  export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
10
10
  const declaredUnsafeRawRunners = new WeakMap();
@@ -53,7 +53,6 @@ function boundRunner(runner, gate, projectionRegistry) {
53
53
  }
54
54
  return value.bind(target);
55
55
  },
56
- // @cast-boundary proxy-erasure — Proxy<object> re-tags as the wrapped DbRunner shape.
57
56
  });
58
57
  if (gate)
59
58
  runnerPersonalDataGates.set(proxy, gate);
@@ -225,12 +224,7 @@ export function createSystemDbView(db, dbOutsideTransaction, report = fallbackEs
225
224
  export function castTenantRows(rows) {
226
225
  return rows;
227
226
  }
228
- export function tableNameOf(table) {
229
- const sym = table[KUMIKO_NAME_SYMBOL];
230
- if (typeof sym === "string")
231
- return sym;
232
- return asEntityTableMeta(table)?.tableName ?? "<unknown>";
233
- }
227
+ export { tableNameOf };
234
228
  // A grant is only real when its reason is non-empty — `{ reason: "" }` must not silently unlock it.
235
229
  function hasGrant(decl) {
236
230
  return decl !== undefined && decl.reason.trim().length > 0;
@@ -247,7 +241,7 @@ export function hasTenantColumn(table) {
247
241
  const meta = asEntityTableMeta(table);
248
242
  if (meta)
249
243
  return meta.columns.some((c) => c.name === "tenant_id");
250
- return table["tenantId"] !== undefined;
244
+ return hasFieldProperty(table, "tenantId");
251
245
  }
252
246
  const unsafeRawRebinders = new WeakMap();
253
247
  // Rebinds tenantDb's unsafeRaw grant (e.g. a hook's own escapeHatch); inputs not built by
@@ -475,7 +469,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
475
469
  report("global-write", globalWriteReason());
476
470
  return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
477
471
  },
478
- // @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
472
+ // @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
479
473
  };
480
474
  }
481
475
  function grantedUnsafeRawRunner(declaredStepReason) {
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  // Deterministic JSON: object keys sorted (recursively), `undefined` values
3
4
  // dropped, arrays keep their order. Two specs that are semantically equal
4
5
  // (same keys, different insertion order; explicit `undefined` vs. omitted)
@@ -7,7 +8,7 @@ export function canonicalJson(value) {
7
8
  if (Array.isArray(value)) {
8
9
  return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
9
10
  }
10
- if (value !== null && typeof value === "object") {
11
+ if (isPlainObject(value)) {
11
12
  const entries = Object.entries(value)
12
13
  .filter(([, v]) => v !== undefined)
13
14
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
@@ -15,7 +15,7 @@ function hasFilterableFields(feature, entityName) {
15
15
  const entity = entities[entityName];
16
16
  if (!entity)
17
17
  return false;
18
- return Object.values(entity.fields).some((raw) => raw.filterable === true);
18
+ return Object.values(entity.fields).some((raw) => "filterable" in raw && raw.filterable === true);
19
19
  }
20
20
  function hasEntityEditDetail(feature, entityName) {
21
21
  return Object.values(feature.screens).some((s) => s.type === "entityEdit" && s.entity === entityName);
@@ -48,7 +48,7 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
48
48
  const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
49
49
  if (field.type === "embedded") {
50
50
  for (const [subName, subField] of Object.entries(field.schema)) {
51
- if (subField.writeOnly === true) {
51
+ if ("writeOnly" in subField && subField.writeOnly === true) {
52
52
  // @cast-boundary schema-walk
53
53
  throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields.`);
54
54
  }
@@ -6,6 +6,7 @@
6
6
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
7
  import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
9
10
  import { rowMetaFieldNames } from "../../db/table-builder.js";
10
11
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
11
12
  import { isEncryptedAtRest } from "../config-helpers.js";
@@ -275,7 +276,7 @@ function validateWizardLayout(featureName, screenId, screenType, layout, feature
275
276
  // for an unregistered key instead of crashing — this check makes the same
276
277
  // gap fail loudly at boot instead of silently at render.
277
278
  function isRegisteredIcon(icon) {
278
- return NAV_ICON_KEYS.includes(icon);
279
+ return isOneOf(NAV_ICON_KEYS, icon);
279
280
  }
280
281
  export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
281
282
  const resolved = resolveActionIcon(action.id, action.icon);
@@ -1,6 +1,6 @@
1
- import { validateChangelog } from "./feature-changelog.js";
1
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { CHANGELOG_TYPES, validateChangelog } from "./feature-changelog.js";
2
3
  const BLOCK_RE = /<!--\s*kumiko-changes\s*\n([\s\S]*?)\n\s*-->/g;
3
- const TYPE_VALUES = new Set(["breaking", "improvement", "fix"]);
4
4
  const KEY_RE = /^(feature|type|title|detail|migration|codemod|manualAfterCodemod):(?:\s(.*))?$/;
5
5
  function requiredField(fields, field, source) {
6
6
  const value = fields.get(field)?.trim();
@@ -95,7 +95,7 @@ function parseManualAfterCodemod(fields, source) {
95
95
  function buildChange(fields, prose, source) {
96
96
  const feature = requiredField(fields, "feature", source);
97
97
  const rawType = requiredField(fields, "type", source);
98
- if (!TYPE_VALUES.has(rawType)) {
98
+ if (!isOneOf(CHANGELOG_TYPES, rawType)) {
99
99
  throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
100
100
  }
101
101
  const { title, detail } = resolveTitleAndDetail(fields, prose, source);
@@ -1,4 +1,5 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { runPipeline } from "./run-pipeline.js";
3
4
  export function defineWriteHandler(def,
4
5
  // R6: a phantom rest-param. When the inferred response `TData` carries a
@@ -88,14 +89,10 @@ export function defineQueryHandler(def,
88
89
  // brand through into the stored registry entry.
89
90
  export const PAGED_QUERY_HANDLER_BRAND = "__kumikoPagedQueryHandler";
90
91
  export function isPagedQueryHandler(def) {
91
- // @cast-boundary brand-probe — reading an internal marker key off an
92
- // otherwise-typed handler definition; the property may legitimately be
93
- // absent, which is exactly the case this function distinguishes.
94
- return def[PAGED_QUERY_HANDLER_BRAND] === true;
92
+ return propertyOf(def, PAGED_QUERY_HANDLER_BRAND) === true;
95
93
  }
96
94
  export function isEntityConventionQueryHandler(def) {
97
- // @cast-boundary brand-probe — same shape as isPagedQueryHandler above.
98
- return def[ENTITY_CONVENTION_QUERY_BRAND] === true;
95
+ return propertyOf(def, ENTITY_CONVENTION_QUERY_BRAND) === true;
99
96
  }
100
97
  // A projectionList screen's query must resolve to { rows, nextCursor, total? }
101
98
  // (CursorResult<T>) — the renderer used to read rowsQuery.data?.rows and
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  /** Computes a derived cell from its source values. Missing/non-numeric
2
3
  * sources: "multiply" with any missing source → undefined; "sum"/"subtract"
3
4
  * treat missing as 0 only when at least one source is present — if every
@@ -6,9 +7,13 @@
6
7
  * to the target sub-field's declared precision afterward. */
7
8
  export function computeDerivedCellValue(op, values) {
8
9
  if (op === "multiply") {
9
- if (values.some((value) => value === undefined))
10
- return undefined;
11
- return values.reduce((product, value) => product * value, 1);
10
+ let product = 1;
11
+ for (const value of values) {
12
+ if (value === undefined)
13
+ return undefined;
14
+ product *= value;
15
+ }
16
+ return product;
12
17
  }
13
18
  if (values.every((value) => value === undefined))
14
19
  return undefined;
@@ -58,10 +63,9 @@ function roundHalfAwayFromZero(value, decimals) {
58
63
  * it's written back, so a fractional product lands on a value the target
59
64
  * type can actually represent. */
60
65
  export function withDerivedCells(row, derived, schema) {
61
- if (typeof row !== "object" || row === null || Array.isArray(row))
66
+ if (!isPlainObject(row))
62
67
  return row;
63
- const source = row;
64
- const copy = { ...source };
68
+ const copy = { ...row };
65
69
  for (const [cellName, def] of Object.entries(derived)) {
66
70
  const sourceValues = def.from.map((sourceField) => {
67
71
  const value = copy[sourceField];
@@ -3,9 +3,10 @@
3
3
  // validation (feature-ui-extensions.ts's useExtension) already rejects an
4
4
  // empty reason, but options stays `Record<string, unknown>`, so orchestrators
5
5
  // re-narrow at read time instead of trusting the shape.
6
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
6
7
  export function extensionUsageEscapeHatchReason(usage) {
7
8
  const escapeHatch = usage.options?.["escapeHatch"];
8
- if (typeof escapeHatch !== "object" || escapeHatch === null)
9
+ if (!isPlainObject(escapeHatch))
9
10
  return undefined;
10
11
  const { reason, grants } = escapeHatch;
11
12
  if (Array.isArray(grants) && !grants.includes("unsafeRaw"))