@cosmicdrift/kumiko-framework 0.165.0 → 0.165.2

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 (157) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -3
  3. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  4. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  5. package/src/api/__tests__/api.test.ts +267 -19
  6. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  7. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  8. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  9. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  10. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +150 -0
  11. package/src/api/__tests__/batch.integration.test.ts +21 -2
  12. package/src/api/__tests__/jwt.test.ts +52 -2
  13. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  14. package/src/api/__tests__/sse-broker.test.ts +57 -0
  15. package/src/api/__tests__/sse-route.test.ts +4 -0
  16. package/src/api/auth-routes.ts +178 -33
  17. package/src/api/index.ts +1 -0
  18. package/src/api/jwt.ts +22 -1
  19. package/src/api/routes.ts +103 -35
  20. package/src/api/server.ts +26 -2
  21. package/src/api/sse-broker.ts +38 -0
  22. package/src/bun-db/index.ts +1 -0
  23. package/src/bun-db/query.ts +14 -5
  24. package/src/consumer-cli.ts +60 -13
  25. package/src/db/__tests__/decimal-field.test.ts +3 -3
  26. package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
  27. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  28. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  29. package/src/db/__tests__/migrate-runner.test.ts +75 -0
  30. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  31. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
  32. package/src/db/__tests__/tenant-db-where-merge.test.ts +10 -6
  33. package/src/db/api.ts +2 -2
  34. package/src/db/bun-provider.ts +2 -2
  35. package/src/db/collect-table-metas.ts +3 -3
  36. package/src/db/connection.ts +6 -3
  37. package/src/db/dialect.ts +1 -6
  38. package/src/db/entity-table-meta-types.ts +1 -1
  39. package/src/db/entity-table-meta.ts +49 -32
  40. package/src/db/event-store-executor-context.ts +2 -3
  41. package/src/db/event-store-executor-read.ts +2 -3
  42. package/src/db/event-store-executor-write.ts +8 -0
  43. package/src/db/index.ts +13 -2
  44. package/src/db/located-timestamp.ts +4 -0
  45. package/src/db/migrate-runner.ts +102 -11
  46. package/src/db/pg-error.ts +8 -0
  47. package/src/db/postgres-provider.ts +2 -2
  48. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  49. package/src/db/queries/ddl.ts +45 -0
  50. package/src/db/queries/event-store.ts +97 -5
  51. package/src/db/queries/test-stack.ts +4 -30
  52. package/src/db/reference-data.ts +2 -3
  53. package/src/db/replay-migration-sql.ts +114 -12
  54. package/src/db/table-builder.ts +2 -2
  55. package/src/db/tenant-db.ts +3 -5
  56. package/src/engine/__tests__/engine.test.ts +30 -0
  57. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  58. package/src/engine/__tests__/store-table.test.ts +10 -13
  59. package/src/engine/boot-validator/nav.ts +5 -0
  60. package/src/engine/constants.ts +28 -6
  61. package/src/engine/create-app.ts +11 -0
  62. package/src/engine/effective-features.ts +12 -2
  63. package/src/engine/extensions/user-data.ts +12 -4
  64. package/src/engine/feature-ast/extractors/round5.ts +1 -1
  65. package/src/engine/feature-ui-extensions.ts +3 -3
  66. package/src/engine/hook-helpers.ts +3 -1
  67. package/src/engine/index.ts +1 -1
  68. package/src/engine/ownership.ts +4 -3
  69. package/src/engine/registry-ingest.ts +14 -14
  70. package/src/engine/registry-state.ts +6 -3
  71. package/src/engine/schema-builder.ts +1 -0
  72. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  73. package/src/engine/steps/_duration-utils.ts +2 -0
  74. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  75. package/src/engine/types/config.ts +1 -1
  76. package/src/engine/types/define-handler.ts +1 -1
  77. package/src/engine/types/entity-handlers.ts +1 -1
  78. package/src/engine/types/event-type-map.ts +1 -1
  79. package/src/engine/types/feature.ts +1 -1
  80. package/src/engine/types/fields.ts +1 -1
  81. package/src/engine/types/handlers.ts +1 -1
  82. package/src/engine/types/hooks.ts +1 -1
  83. package/src/engine/types/http-route.ts +1 -1
  84. package/src/engine/types/nav.ts +1 -1
  85. package/src/engine/types/ownership.ts +1 -1
  86. package/src/engine/types/projection.ts +1 -1
  87. package/src/engine/types/relations.ts +1 -1
  88. package/src/engine/types/screen.ts +1 -1
  89. package/src/engine/types/step.ts +1 -1
  90. package/src/engine/types/target-ref.ts +1 -1
  91. package/src/engine/types/tree-node.ts +1 -1
  92. package/src/engine/types/workspace.ts +1 -1
  93. package/src/engine/validate-projection-allowlist.ts +6 -6
  94. package/src/errors/classes.ts +21 -0
  95. package/src/errors/index.ts +1 -0
  96. package/src/errors/write-error-info.ts +6 -2
  97. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  98. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  99. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  100. package/src/event-store/admin-api.ts +11 -4
  101. package/src/event-store/event-store.ts +19 -4
  102. package/src/event-store/types.ts +1 -1
  103. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  104. package/src/files/__tests__/local-provider.test.ts +31 -0
  105. package/src/files/__tests__/write-stream.test.ts +3 -3
  106. package/src/files/index.ts +1 -1
  107. package/src/files/local-provider.ts +6 -1
  108. package/src/files/types.ts +8 -1
  109. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  110. package/src/jobs/job-runner.ts +41 -11
  111. package/src/logging/types.ts +1 -1
  112. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
  113. package/src/migrations/projection-table-index.ts +1 -1
  114. package/src/observability/index.ts +1 -0
  115. package/src/observability/standard-metrics.ts +35 -2
  116. package/src/observability/types/index.ts +1 -1
  117. package/src/observability/types/metric.ts +1 -1
  118. package/src/observability/types/provider.ts +1 -1
  119. package/src/observability/types/span.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +212 -0
  121. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  122. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  123. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  124. package/src/pipeline/dispatch-shared.ts +59 -6
  125. package/src/pipeline/dispatch-stream.ts +50 -11
  126. package/src/pipeline/dispatcher.ts +7 -1
  127. package/src/pipeline/event-consumer-state.ts +16 -13
  128. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  129. package/src/pipeline/event-dispatcher.ts +22 -0
  130. package/src/pipeline/index.ts +2 -0
  131. package/src/pipeline/system-hooks.ts +87 -0
  132. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  133. package/src/rate-limit/resolver.ts +6 -2
  134. package/src/schema-cli.ts +24 -12
  135. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  136. package/src/search/reindex-entity.ts +31 -2
  137. package/src/search/types.ts +1 -1
  138. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  139. package/src/stack/db.ts +2 -1
  140. package/src/stack/push-entity-projection-tables.ts +2 -1
  141. package/src/stack/request-helper.ts +20 -1
  142. package/src/stack/table-helpers.ts +6 -4
  143. package/src/stack/test-stack.ts +18 -15
  144. package/src/testing/__tests__/late-bound.test.ts +7 -0
  145. package/src/testing/__tests__/wait-for.test.ts +6 -0
  146. package/src/testing/file-provider-contract.ts +26 -6
  147. package/src/testing/index.ts +1 -0
  148. package/src/testing/late-bound.ts +5 -3
  149. package/src/testing/wait-for.ts +3 -0
  150. package/src/testing/without-ambient-temporal.ts +14 -0
  151. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  152. package/src/time/geo-tz.ts +1 -1
  153. package/src/time/polyfill.ts +28 -39
  154. package/src/time/tz-context.ts +30 -24
  155. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  156. package/src/utils/safe-json.ts +3 -2
  157. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -0,0 +1,80 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { type BunTestDb, createTestDb } from "../../../bun-db/__tests__/bun-test-db";
3
+ import { createEventsTable } from "../../../event-store/events-schema";
4
+ import { asRawClient } from "../../query";
5
+ import { ensureIdempotencyKeyIndex } from "../event-store";
6
+
7
+ let testDb: BunTestDb;
8
+
9
+ beforeAll(async () => {
10
+ testDb = await createTestDb();
11
+ await createEventsTable(testDb.db);
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await testDb.cleanup();
16
+ });
17
+
18
+ async function indexValidity(): Promise<boolean | undefined> {
19
+ const rows = (await asRawClient(testDb.db).unsafe(
20
+ `SELECT i.indisvalid FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid ` +
21
+ `WHERE c.relname = 'events_idempotency_uq'`,
22
+ )) as ReadonlyArray<{ indisvalid: boolean }>;
23
+ return rows[0]?.indisvalid;
24
+ }
25
+
26
+ describe("ensureIdempotencyKeyIndex (#1499)", () => {
27
+ test("repeated calls stay idempotent — index ends up valid, no throw", async () => {
28
+ await ensureIdempotencyKeyIndex(testDb.db);
29
+ await ensureIdempotencyKeyIndex(testDb.db);
30
+ expect(await indexValidity()).toBe(true);
31
+ });
32
+
33
+ // Regression for the rolling-deploy race: two pods booting concurrently
34
+ // against the same DB both try to build the same CONCURRENTLY index.
35
+ // A plain CREATE INDEX IF NOT EXISTS no-ops the loser; a CONCURRENTLY
36
+ // build can instead raise a duplicate-relation error mid-build — that
37
+ // must be swallowed the same way createEventsTable already tolerates a
38
+ // racing CREATE TABLE, not crash-loop the booting pod.
39
+ test("concurrent calls from two racing boots don't throw", async () => {
40
+ // The prior test already built the index — without dropping it here,
41
+ // both calls below would just hit the IF NOT EXISTS no-op path (indeed
42
+ // what the original single-connection version of this test silently
43
+ // did) and never actually contend on a real CONCURRENTLY build.
44
+ await asRawClient(testDb.db).unsafe(
45
+ `DROP INDEX CONCURRENTLY IF EXISTS "events_idempotency_uq"`,
46
+ );
47
+
48
+ // Two independent connections against the SAME database, mirroring two
49
+ // pods — a single shared `testDb.db` pool would serialize both calls
50
+ // and never pin the race (kumiko-framework#1522).
51
+ const secondPodDb = await createTestDb({ dbName: testDb.dbName, persistent: true });
52
+ try {
53
+ await Promise.all([
54
+ ensureIdempotencyKeyIndex(testDb.db),
55
+ ensureIdempotencyKeyIndex(secondPodDb.db),
56
+ ]);
57
+ expect(await indexValidity()).toBe(true);
58
+ } finally {
59
+ // persistent: true → cleanup() only closes this pool, doesn't drop the
60
+ // shared DB (testDb's afterAll still owns that).
61
+ await secondPodDb.cleanup();
62
+ }
63
+ });
64
+
65
+ test("an INVALID leftover from a killed CONCURRENTLY build gets dropped and rebuilt", async () => {
66
+ // Simulates a build interrupted mid-flight (crash, deploy restart): the
67
+ // catalog entry exists but never finished — pg_index.indisvalid=false.
68
+ // Postgres has no direct DDL to force this state, so flip it straight
69
+ // in the catalog, matching what a real killed CONCURRENTLY build leaves
70
+ // behind.
71
+ await asRawClient(testDb.db).unsafe(
72
+ `UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'events_idempotency_uq'::regclass`,
73
+ );
74
+ expect(await indexValidity()).toBe(false);
75
+
76
+ await ensureIdempotencyKeyIndex(testDb.db);
77
+
78
+ expect(await indexValidity()).toBe(true);
79
+ });
80
+ });
@@ -0,0 +1,45 @@
1
+ import type { AnyDb } from "../query";
2
+ import { asRawClient } from "../query";
3
+ import { quoteTableIdent } from "./table-ops";
4
+
5
+ // Generic DDL helpers used on the prod-boot path (stack/table-helpers.ts's
6
+ // unsafePushTables, event-consumer-state.ts's multi-instance backfill) —
7
+ // split out of queries/test-stack.ts so a prod-boot import doesn't point at
8
+ // a module named for test-only concerns (truncate/create/drop-database).
9
+
10
+ export async function executeDdlStatement(db: AnyDb, sqlText: string): Promise<void> {
11
+ await asRawClient(db).unsafe(sqlText);
12
+ }
13
+
14
+ export async function alterTableAddColumn(
15
+ db: AnyDb,
16
+ tableName: string,
17
+ columnName: string,
18
+ columnType: string,
19
+ defaultClause: string,
20
+ notNull: string,
21
+ // table-helpers.ts's unmanaged-table sync relies on the plain form
22
+ // throwing when a column already exists with a different shape than
23
+ // EntityTableMeta expects — that's how it surfaces drift. Callers that
24
+ // just need an idempotent, race-safe backfill (e.g. event-consumer-state's
25
+ // multi-instance boot path, #1362) opt in explicitly.
26
+ ifNotExists = false,
27
+ ): Promise<void> {
28
+ await asRawClient(db).unsafe(
29
+ `ALTER TABLE ${quoteTableIdent(tableName)} ADD COLUMN ${ifNotExists ? "IF NOT EXISTS " : ""}${quoteTableIdent(columnName)} ${columnType}${defaultClause}${notNull}`,
30
+ );
31
+ }
32
+
33
+ export async function createIndexIfNotExists(
34
+ db: AnyDb,
35
+ indexKind: "UNIQUE INDEX" | "INDEX",
36
+ indexName: string,
37
+ tableName: string,
38
+ columnList: string,
39
+ whereSql?: string,
40
+ ): Promise<void> {
41
+ const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
42
+ await asRawClient(db).unsafe(
43
+ `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
44
+ );
45
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ constraintOf,
3
+ isLockNotAvailable,
4
+ isTableAlreadyExists,
5
+ isUniqueViolation,
6
+ } from "../pg-error";
1
7
  import type { AnyDb } from "../query";
2
8
  import { asRawClient } from "../query";
3
9
 
@@ -12,12 +18,98 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
12
18
  // (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
13
19
  // on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
14
20
  // installs that predate the index without a table rebuild.
15
- export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
16
- await asRawClient(db).unsafe(
17
- `CREATE UNIQUE INDEX IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
18
- `("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
19
- `WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
21
+ //
22
+ // CONCURRENTLY (not a plain CREATE): a non-concurrent build takes a SHARE
23
+ // lock for the full table scan on kumiko_events the hottest table in the
24
+ // framework — blocking every append() for however long that scan takes on
25
+ // an existing installation's event history. CONCURRENTLY avoids that at the
26
+ // cost of needing to tolerate two failure modes a plain build doesn't have.
27
+ // Neither CREATE nor DROP ... CONCURRENTLY may run inside a transaction —
28
+ // no caller of this function (dev-server, schema-cli.ts, stack/db.ts) may
29
+ // wrap it in one, or Postgres raises 25001.
30
+ // undefined = index doesn't exist at all (nothing to drop, CREATE below
31
+ // handles it); false = exists but INVALID (crashed mid-build, needs DROP +
32
+ // rebuild); true = exists and valid.
33
+ async function indexValidity(client: ReturnType<typeof asRawClient>): Promise<boolean | undefined> {
34
+ const rows = await client.unsafe(
35
+ `SELECT i.indisvalid FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid ` +
36
+ `WHERE c.relname = 'events_idempotency_uq' AND i.indrelid = '"kumiko_events"'::regclass`,
20
37
  );
38
+ return (rows[0] as { indisvalid?: boolean } | undefined)?.indisvalid;
39
+ }
40
+
41
+ export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
42
+ const client = asRawClient(db);
43
+
44
+ try {
45
+ // 1) A prior CONCURRENTLY build that got killed mid-flight (crash, deploy
46
+ // restart) leaves an INVALID index: the catalog entry exists, so
47
+ // IF NOT EXISTS below would silently skip forever, but the index is
48
+ // incomplete and not plannable for queries — it does NOT mean the
49
+ // constraint enforces nothing; Postgres keeps maintaining an INVALID
50
+ // index on every insert, it just refuses to use it for planning.
51
+ // Detect + rebuild it. Scoped to kumiko_events specifically (indrelid),
52
+ // not just the relname, so a same-named index in another schema can't
53
+ // false-positive this DROP.
54
+ if ((await indexValidity(client)) === false) {
55
+ await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS "events_idempotency_uq"`);
56
+ }
57
+
58
+ await client.unsafe(
59
+ `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
60
+ `("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
61
+ `WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
62
+ );
63
+ } catch (e) {
64
+ if (isBenignConcurrentIndexBuildRace(e)) {
65
+ // "Benign" only means the losing side of a race, not that the index
66
+ // actually landed valid — a lock_timeout on the CREATE (55P03) backs
67
+ // off the same way a genuine duplicate-build race does, but leaves no
68
+ // valid index at all. Re-check before declaring victory instead of
69
+ // trusting the error class alone.
70
+ // skip: sibling pod already built a valid index — this race loser is done.
71
+ if ((await indexValidity(client)) === true) return;
72
+ console.warn(
73
+ `ensureIdempotencyKeyIndex: backed off on a "benign" race (${String(e)}) but ` +
74
+ `"events_idempotency_uq" is still missing/invalid afterward — likely a ` +
75
+ `lock_timeout during CREATE INDEX CONCURRENTLY, not an actual winner/loser race.`,
76
+ );
77
+ throw e;
78
+ }
79
+ throw duplicateIdempotencyKeyErrorOr(e);
80
+ }
81
+ }
82
+
83
+ // Two pods booting concurrently against the same DB (rolling deploy): both
84
+ // see the index missing/invalid and both start a DROP/CREATE CONCURRENTLY
85
+ // build. The loser typically does NOT get the plain duplicate-relation
86
+ // no-op IF NOT EXISTS normally gives — it can instead see a unique-violation
87
+ // on Postgres' own pg_class catalog insert (23505, constraint
88
+ // pg_class_relname_nsp_index) or a lock-not-available (55P03) from the
89
+ // racing DDL. Both are benign: the other pod's build wins and this one just
90
+ // backs off. A 23505 against "events_idempotency_uq" itself is NOT this
91
+ // race — see duplicateIdempotencyKeyErrorOr.
92
+ function isBenignConcurrentIndexBuildRace(e: unknown): boolean {
93
+ if (isTableAlreadyExists(e) || isLockNotAvailable(e)) return true;
94
+ return isUniqueViolation(e) && constraintOf(e) === "pg_class_relname_nsp_index";
95
+ }
96
+
97
+ // A 23505 against "events_idempotency_uq" means real duplicate
98
+ // metadata->>'idempotencyKey' values for the same tenant already exist —
99
+ // CONCURRENTLY still enforces uniqueness on live inserts against the
100
+ // not-yet-valid index. That needs an operator to find + resolve the
101
+ // duplicates, not a crash-loop on every subsequent boot, so re-throw a
102
+ // distinguishable error instead of the raw driver error.
103
+ function duplicateIdempotencyKeyErrorOr(e: unknown): unknown {
104
+ if (isUniqueViolation(e) && constraintOf(e) === "events_idempotency_uq") {
105
+ return new Error(
106
+ "ensureIdempotencyKeyIndex: duplicate metadata->>'idempotencyKey' values exist for at least " +
107
+ "one tenant in kumiko_events — CREATE UNIQUE INDEX CONCURRENTLY cannot complete. Find and " +
108
+ "resolve the duplicate idempotencyKey rows, then restart to retry.",
109
+ { cause: e },
110
+ );
111
+ }
112
+ return e;
21
113
  }
22
114
 
23
115
  export type SubsequentEventInsertParams = {
@@ -2,36 +2,10 @@ import type { AnyDb } from "../query";
2
2
  import { asRawClient } from "../query";
3
3
  import { quoteTableIdent } from "./table-ops";
4
4
 
5
- export async function executeDdlStatement(db: AnyDb, sqlText: string): Promise<void> {
6
- await asRawClient(db).unsafe(sqlText);
7
- }
8
-
9
- export async function alterTableAddColumn(
10
- db: AnyDb,
11
- tableName: string,
12
- columnName: string,
13
- columnType: string,
14
- defaultClause: string,
15
- notNull: string,
16
- ): Promise<void> {
17
- await asRawClient(db).unsafe(
18
- `ALTER TABLE ${quoteTableIdent(tableName)} ADD COLUMN ${quoteTableIdent(columnName)} ${columnType}${defaultClause}${notNull}`,
19
- );
20
- }
21
-
22
- export async function createIndexIfNotExists(
23
- db: AnyDb,
24
- indexKind: "UNIQUE INDEX" | "INDEX",
25
- indexName: string,
26
- tableName: string,
27
- columnList: string,
28
- whereSql?: string,
29
- ): Promise<void> {
30
- const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
31
- await asRawClient(db).unsafe(
32
- `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
33
- );
34
- }
5
+ // Re-exported for back-compat the generic DDL helpers moved to ./ddl so
6
+ // the prod-boot path (stack/table-helpers.ts, pipeline/event-consumer-state.ts)
7
+ // doesn't import from a module named for test-only concerns.
8
+ export { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement } from "./ddl";
35
9
 
36
10
  export async function truncateTablesRestartIdentity(
37
11
  db: AnyDb,
@@ -1,3 +1,4 @@
1
+ import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
1
2
  import { fetchOne, insertOne, updateMany } from "../db/query";
2
3
  import type { ReferenceDataDef } from "../engine/types";
3
4
  import { SYSTEM_TENANT_ID } from "../engine/types";
@@ -7,10 +8,8 @@ import type { TableColumns } from "./dialect";
7
8
  // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables
8
9
  type Table = TableColumns<any>;
9
10
 
10
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
11
-
12
11
  function hasColumn(table: Table, field: string): boolean {
13
- const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
12
+ const cols = table[KUMIKO_COLUMNS_SYMBOL];
14
13
  if (typeof cols !== "object" || cols === null) return false;
15
14
  return field in (cols as Record<string, unknown>);
16
15
  }
@@ -12,6 +12,23 @@
12
12
  import type { Snapshot } from "./migrate-generator";
13
13
  import { loadMigrationsFromDir } from "./migrate-runner";
14
14
 
15
+ // Migration files comment out destructive ops (DROP TABLE/COLUMN) as
16
+ // `-- DESTRUCTIVE: <stmt>; -- uncomment + ensure backup` so the real
17
+ // migrate-runner never executes them unattended. For replay purposes the
18
+ // snapshot represents the INTENDED end state, so a commented-out drop must
19
+ // still count as applied here — otherwise a table/column the snapshot
20
+ // already omits shows up as "unexpected" forever. splitSqlStatements (the
21
+ // real runner's splitter) strips `--`-comments outright, which would erase
22
+ // these markers before they ever reach applyStatement.
23
+ const DESTRUCTIVE_MARKER = /^--\s*DESTRUCTIVE:\s*(.+?;)/i;
24
+
25
+ function expandDestructiveMarkers(sqlText: string): string {
26
+ return sqlText
27
+ .split("\n")
28
+ .map((line) => DESTRUCTIVE_MARKER.exec(line.trim())?.[1] ?? line)
29
+ .join("\n");
30
+ }
31
+
15
32
  export type ReplayedTable = {
16
33
  readonly columns: ReadonlySet<string>;
17
34
  };
@@ -39,28 +56,67 @@ function splitTopLevel(body: string): readonly string[] {
39
56
  return parts;
40
57
  }
41
58
 
59
+ // Identifiers in hand-written migrations aren't always quoted (the generator
60
+ // always quotes, but the header explicitly invites hand-editing) — optional
61
+ // quotes so `CREATE TABLE foo (...)` parses the same as `CREATE TABLE "foo" (...)`.
62
+ const IDENT = `"?([^"\\s(;,.]+)"?`;
63
+
42
64
  function parseColumnNames(body: string): Set<string> {
43
65
  const columns = new Set<string>();
44
66
  for (const part of splitTopLevel(body)) {
45
67
  const trimmed = part.trim();
46
- if (/^CONSTRAINT\b/i.test(trimmed)) continue; // composite-PK line, not a column
47
- const match = trimmed.match(/^"([^"]+)"/);
68
+ if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE|LIKE)\b/i.test(trimmed)) continue; // table-constraint or LIKE clause, not a column
69
+ const match = trimmed.match(new RegExp(`^${IDENT}`));
48
70
  if (match?.[1] !== undefined) columns.add(match[1]);
49
71
  }
50
72
  return columns;
51
73
  }
52
74
 
53
- function applyStatement(schema: Map<string, { columns: Set<string> }>, statement: string): void {
75
+ // Clauses that can appear inside an ALTER TABLE body which do NOT change the
76
+ // table/column shape this replay tracks (presence only, not types/
77
+ // constraints/RLS/ownership) — recognized explicitly so they don't fall
78
+ // through to the fail-loud check as "unparsed". Deliberately does NOT
79
+ // include RENAME TO / RENAME COLUMN: those DO change identity in a way this
80
+ // replay can't track, so they must keep failing loud.
81
+ const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
82
+ [
83
+ `ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
84
+ `ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
85
+ `ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
86
+ `(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
87
+ `\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
88
+ `^OWNER TO\\b`,
89
+ ].join("|"),
90
+ "gi",
91
+ );
92
+
93
+ function applyStatement(
94
+ schema: Map<string, { columns: Set<string> }>,
95
+ statement: string,
96
+ context: { readonly file: string },
97
+ ): void {
54
98
  const create = statement.match(
55
- /^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s*\(([\s\S]*)\);?\s*$/i,
99
+ new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"),
56
100
  );
57
- if (create?.[1] !== undefined && create[2] !== undefined) {
58
- schema.set(create[1], { columns: parseColumnNames(create[2]) });
101
+ if (create?.[2] !== undefined && create[3] !== undefined) {
102
+ const hasIfNotExists = create[1] !== undefined;
103
+ // A real Postgres CREATE TABLE IF NOT EXISTS is a no-op when the table
104
+ // already exists — treating it as an overwrite here loses any columns
105
+ // an earlier ALTER TABLE added in between (0001 CREATE, 0002 ALTER ADD
106
+ // COLUMN, 0003 an accidental copy-paste of 0001) and reports a false
107
+ // column-drift for exactly the copy-paste bug this replay exists to
108
+ // catch. A bare CREATE TABLE (no IF NOT EXISTS) still overwrites — the
109
+ // explicit recreate path (migrate-generator.ts) always DROPs first, so
110
+ // reaching a second CREATE for the same name there is itself already
111
+ // the bug the replay should surface via the resulting drift.
112
+ if (!(hasIfNotExists && schema.has(create[2]))) {
113
+ schema.set(create[2], { columns: parseColumnNames(create[3]) });
114
+ }
59
115
  // skip: CREATE TABLE fully handled above, no other clause can also match
60
116
  return;
61
117
  }
62
118
 
63
- const dropTable = statement.match(/^DROP TABLE\s+(?:IF EXISTS\s+)?"([^"]+)"/i);
119
+ const dropTable = statement.match(new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i"));
64
120
  if (dropTable?.[1] !== undefined) {
65
121
  schema.delete(dropTable[1]);
66
122
  // skip: DROP TABLE fully handled above, no other clause can also match
@@ -73,29 +129,75 @@ function applyStatement(schema: Map<string, { columns: Set<string> }>, statement
73
129
  // instead of matching only the first clause, in statement order so an
74
130
  // add-then-drop of the same column (unusual, but not impossible) resolves
75
131
  // correctly.
76
- const alterTable = statement.match(/^ALTER TABLE\s+"([^"]+)"\s+([\s\S]*?);?\s*$/i);
132
+ const alterTable = statement.match(
133
+ new RegExp(`^ALTER TABLE\\s+${IDENT}\\s+([\\s\\S]*?);?\\s*$`, "i"),
134
+ );
77
135
  const alterTableName = alterTable?.[1];
78
136
  const alterBody = alterTable?.[2];
79
137
  if (alterTableName !== undefined && alterBody !== undefined) {
80
138
  const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
81
139
  schema.set(alterTableName, table);
82
- const clauseRe = /(ADD|DROP)\s+COLUMN\s+(?:IF (?:NOT )?EXISTS\s+)?"([^"]+)"/gi;
140
+ const clauseRe = new RegExp(
141
+ `(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`,
142
+ "gi",
143
+ );
144
+ let matchedAClause = false;
83
145
  for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
84
146
  if (verb === undefined || name === undefined) continue;
147
+ matchedAClause = true;
85
148
  if (verb.toUpperCase() === "ADD") table.columns.add(name);
86
149
  else table.columns.delete(name);
87
150
  }
151
+ // Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
152
+ // DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER
153
+ // TO) — none add/remove/rename a column, so this replay (which only
154
+ // tracks column presence) correctly has nothing to do for them.
155
+ if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
156
+ // An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
157
+ // but whose body contains no recognized clause (e.g. RENAME TO/RENAME
158
+ // COLUMN, which DO change identity) would otherwise silently no-op here
159
+ // — fall through to the fail-loud check below instead of returning, so
160
+ // it's reported rather than vanishing.
161
+ // skip: at least one recognized clause matched — this ALTER TABLE is
162
+ // fully handled, nothing left to do.
163
+ if (matchedAClause) return;
88
164
  }
89
165
  // else: CREATE INDEX and everything else don't change the table/column
90
- // shape this replay tracks.
166
+ // shape this replay tracks — but a statement that clearly INTENDED to
167
+ // touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
168
+ // none of the recognized patterns above must fail loud, not vanish
169
+ // silently. Concretely this is RENAME TO / RENAME COLUMN (identity change
170
+ // this replay can't track) or genuinely unparsed hand-written DDL — either
171
+ // way a misleading missing-table/column-drift report is worse than
172
+ // pointing at the actual unparsed statement.
173
+ if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
174
+ const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
175
+ throw new Error(
176
+ `replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
177
+ `starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
178
+ `recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
179
+ `COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
180
+ `ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
181
+ `optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
182
+ `identity change, not trackable here) or genuinely unparsed hand-written ` +
183
+ `DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
184
+ );
185
+ }
91
186
  }
92
187
 
93
188
  // Reads `<migrationsDir>/*.sql` in sequence order and replays every
94
189
  // CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
190
+ // Reuses the real runner's file-discovery + statement-splitting
191
+ // (loadMigrationsFromDir) instead of a second copy, so any future change to
192
+ // sub-directory handling, numeric sort order, or the .sql filter can't
193
+ // silently drift between the runner and this replay (#1522/9).
95
194
  export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
96
195
  const schema = new Map<string, { columns: Set<string> }>();
97
- for (const migration of loadMigrationsFromDir(migrationsDir)) {
98
- for (const statement of migration.statements) applyStatement(schema, statement);
196
+ const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
197
+ for (const migration of migrations) {
198
+ for (const statement of migration.statements) {
199
+ applyStatement(schema, statement, { file: migration.id });
200
+ }
99
201
  }
100
202
  return schema;
101
203
  }
@@ -526,7 +526,7 @@ export function buildEntityTable<E extends EntityDefinition>(
526
526
  }
527
527
  }
528
528
  // lookupable-Felder: Index auf der bidx-Spalte (lock-step mit
529
- // buildEntityTableMeta).
529
+ // deriveEntityTableMeta).
530
530
  const bidxFieldByField = new Map<string, string>();
531
531
  for (const [name, field] of Object.entries(entity.fields)) {
532
532
  if (field.type !== "text" || field.lookupable !== true) continue;
@@ -558,7 +558,7 @@ export function buildEntityTable<E extends EntityDefinition>(
558
558
  }
559
559
  indexes[indexName] = chain;
560
560
  // Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
561
- // (lock-step mit buildEntityTableMeta).
561
+ // (lock-step mit deriveEntityTableMeta).
562
562
  if (def.unique === true && def.where === undefined) {
563
563
  const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
564
564
  if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
@@ -1,4 +1,4 @@
1
- import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
1
+ import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
3
  import {
4
4
  asEntityTableMeta,
@@ -24,15 +24,13 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
24
24
  return rows as unknown as readonly T[];
25
25
  }
26
26
 
27
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
28
-
29
27
  function tableNameOf(table: Table): string {
30
- const sym = (table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
28
+ const sym = table[KUMIKO_NAME_SYMBOL];
31
29
  return typeof sym === "string" ? sym : "<unknown>";
32
30
  }
33
31
 
34
32
  // Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
35
- // or a plain buildEntityTableMeta/defineUnmanagedTable result), not a direct
33
+ // or a plain deriveEntityTableMeta/defineUnmanagedTable result), not a direct
36
34
  // `table.tenantId` property read — the latter only exists on branded EntityTables
37
35
  // and silently returned false (no tenant filter!) for plain EntityTableMeta
38
36
  // tables like unmanaged direct-write stores, e.g. userSessionTable.
@@ -739,6 +739,36 @@ describe("createApp", () => {
739
739
  ).not.toThrow();
740
740
  });
741
741
 
742
+ test("validates stream handler roles against app-defined roles (#1442)", () => {
743
+ const feature = defineFeature("admin", (r) => {
744
+ r.streamHandler("admin.tail", z.object({}), async function* () {}, {
745
+ access: { roles: ["SuperAdmin"] },
746
+ });
747
+ });
748
+
749
+ expect(() =>
750
+ createApp({
751
+ roles: ["Admin", "User"] as const,
752
+ features: [feature],
753
+ }),
754
+ ).toThrow(/unknown role.*SuperAdmin/i);
755
+ });
756
+
757
+ test("passes when stream handler roles are valid (#1442)", () => {
758
+ const feature = defineFeature("admin", (r) => {
759
+ r.streamHandler("admin.tail", z.object({}), async function* () {}, {
760
+ access: { roles: ["Admin"] },
761
+ });
762
+ });
763
+
764
+ expect(() =>
765
+ createApp({
766
+ roles: ["Admin", "User"] as const,
767
+ features: [feature],
768
+ }),
769
+ ).not.toThrow();
770
+ });
771
+
742
772
  test("createApp returns registry", () => {
743
773
  const feature = defineFeature("test", () => {});
744
774
  const app = createApp({ roles: ["Admin"] as const, features: [feature] });
@@ -113,6 +113,24 @@ describe("buildInsertSchema", () => {
113
113
  valid: { age: 25 },
114
114
  invalid: { age: "old" },
115
115
  },
116
+ {
117
+ name: "number field rejects below min",
118
+ fields: { age: createNumberField({ min: 0 }) },
119
+ valid: { age: 0 },
120
+ invalid: { age: -1 },
121
+ },
122
+ {
123
+ name: "number field rejects above max",
124
+ fields: { age: createNumberField({ max: 100 }) },
125
+ valid: { age: 100 },
126
+ invalid: { age: 101 },
127
+ },
128
+ {
129
+ name: "number field min+max bounds",
130
+ fields: { age: createNumberField({ min: 1, max: 10 }) },
131
+ valid: { age: 5 },
132
+ invalid: { age: 11 },
133
+ },
116
134
  {
117
135
  name: "date field",
118
136
  fields: { born: createDateField() },
@@ -7,8 +7,8 @@
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
9
  import {
10
- buildEntityTableMeta,
11
10
  defineUnmanagedTable,
11
+ deriveEntityTableMeta,
12
12
  resolveTableName,
13
13
  } from "../../db/entity-table-meta";
14
14
  import { defineFeature } from "../define-feature";
@@ -67,7 +67,7 @@ describe("r.storeTable — declaration", () => {
67
67
  table: "rt_probe_managed",
68
68
  fields: { name: createTextField() },
69
69
  });
70
- const managedMeta = buildEntityTableMeta("rt-probe-managed", managedEntity);
70
+ const managedMeta = deriveEntityTableMeta("rt-probe-managed", managedEntity);
71
71
  expect(() =>
72
72
  defineFeature("probe", (r) => {
73
73
  r.storeTable(managedMeta, { reason: "test" });
@@ -75,14 +75,11 @@ describe("r.storeTable — declaration", () => {
75
75
  ).toThrow(/requires source: "unmanaged"/);
76
76
  });
77
77
 
78
- test("rejects a table name with the reserved read_ prefix (#1220)", () => {
79
- const readPrefixed = defineUnmanagedTable({
80
- tableName: "read_rt_probe",
81
- columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
82
- });
78
+ test("rejects a table name with the reserved read_ prefix (#1220/#1208)", () => {
83
79
  expect(() =>
84
- defineFeature("probe", (r) => {
85
- r.storeTable(readPrefixed, { reason: "test" });
80
+ defineUnmanagedTable({
81
+ tableName: "read_rt_probe",
82
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
86
83
  }),
87
84
  ).toThrow(/the "read_" prefix is reserved/);
88
85
  });
@@ -142,7 +139,7 @@ describe("createRegistry — storeTable aggregation", () => {
142
139
  r.storeTable(probeMeta, { reason: "second" });
143
140
  });
144
141
  expect(() => createRegistry([featA, featB])).toThrow(
145
- /Raw-table "rt_probe" registered by both feature "a" and "b"/,
142
+ /Store-table "rt_probe" registered by both feature "a" and "b"/,
146
143
  );
147
144
  });
148
145
 
@@ -177,7 +174,7 @@ describe("createRegistry — storeTable aggregation", () => {
177
174
 
178
175
  // Entity registered first, then the colliding store table.
179
176
  expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
180
- new RegExp(`Raw-table "${physical}".*collides with the physical table of entity "widget"`),
177
+ new RegExp(`Store-table "${physical}".*collides with the physical table of entity "widget"`),
181
178
  );
182
179
 
183
180
  // Order-independent: store table registered first, then the entity.
@@ -195,9 +192,9 @@ describe("createRegistry — store tables with PII-annotated fields (#820)", ()
195
192
  ip: createTextField({ userOwned: { ownerField: "userId" } }),
196
193
  },
197
194
  });
198
- const piiMeta = buildEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
195
+ const piiMeta = deriveEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
199
196
 
200
- test("buildEntityTableMeta records the subject-annotated field names", () => {
197
+ test("deriveEntityTableMeta records the subject-annotated field names", () => {
201
198
  expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
202
199
  });
203
200