@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 (146) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +267 -19
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  13. package/src/api/__tests__/sse-broker.test.ts +57 -0
  14. package/src/api/__tests__/sse-route.test.ts +4 -0
  15. package/src/api/auth-routes.ts +165 -33
  16. package/src/api/index.ts +1 -0
  17. package/src/api/jwt.ts +22 -1
  18. package/src/api/routes.ts +103 -35
  19. package/src/api/server.ts +17 -1
  20. package/src/api/sse-broker.ts +39 -0
  21. package/src/bun-db/index.ts +1 -0
  22. package/src/bun-db/query.ts +12 -3
  23. package/src/consumer-cli.ts +60 -13
  24. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  25. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  26. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  27. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  28. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  29. package/src/db/api.ts +2 -2
  30. package/src/db/bun-provider.ts +2 -2
  31. package/src/db/connection.ts +6 -3
  32. package/src/db/dialect.ts +1 -6
  33. package/src/db/entity-table-meta-types.ts +1 -1
  34. package/src/db/event-store-executor-context.ts +2 -3
  35. package/src/db/event-store-executor-read.ts +2 -3
  36. package/src/db/event-store-executor-write.ts +8 -0
  37. package/src/db/index.ts +8 -1
  38. package/src/db/located-timestamp.ts +4 -0
  39. package/src/db/migrate-runner.ts +107 -11
  40. package/src/db/pg-error.ts +8 -0
  41. package/src/db/postgres-provider.ts +2 -2
  42. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  43. package/src/db/queries/ddl.ts +45 -0
  44. package/src/db/queries/event-store.ts +97 -5
  45. package/src/db/queries/test-stack.ts +4 -30
  46. package/src/db/reference-data.ts +2 -3
  47. package/src/db/replay-migration-sql.ts +114 -12
  48. package/src/db/tenant-db.ts +2 -4
  49. package/src/engine/__tests__/engine.test.ts +30 -0
  50. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  51. package/src/engine/__tests__/store-table.test.ts +2 -2
  52. package/src/engine/boot-validator/nav.ts +5 -0
  53. package/src/engine/constants.ts +32 -6
  54. package/src/engine/create-app.ts +11 -0
  55. package/src/engine/effective-features.ts +12 -2
  56. package/src/engine/extensions/user-data.ts +12 -4
  57. package/src/engine/feature-ui-extensions.ts +2 -2
  58. package/src/engine/hook-helpers.ts +3 -1
  59. package/src/engine/index.ts +1 -1
  60. package/src/engine/ownership.ts +4 -3
  61. package/src/engine/registry-ingest.ts +14 -14
  62. package/src/engine/registry-state.ts +4 -1
  63. package/src/engine/schema-builder.ts +1 -0
  64. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  65. package/src/engine/steps/_duration-utils.ts +2 -0
  66. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  67. package/src/engine/types/config.ts +1 -1
  68. package/src/engine/types/define-handler.ts +1 -1
  69. package/src/engine/types/entity-handlers.ts +1 -1
  70. package/src/engine/types/event-type-map.ts +1 -1
  71. package/src/engine/types/feature.ts +1 -1
  72. package/src/engine/types/fields.ts +1 -1
  73. package/src/engine/types/handlers.ts +1 -1
  74. package/src/engine/types/hooks.ts +1 -1
  75. package/src/engine/types/http-route.ts +1 -1
  76. package/src/engine/types/nav.ts +1 -1
  77. package/src/engine/types/ownership.ts +1 -1
  78. package/src/engine/types/projection.ts +1 -1
  79. package/src/engine/types/relations.ts +1 -1
  80. package/src/engine/types/screen.ts +1 -1
  81. package/src/engine/types/step.ts +1 -1
  82. package/src/engine/types/target-ref.ts +1 -1
  83. package/src/engine/types/tree-node.ts +1 -1
  84. package/src/engine/types/workspace.ts +1 -1
  85. package/src/engine/validate-projection-allowlist.ts +5 -5
  86. package/src/errors/classes.ts +21 -0
  87. package/src/errors/index.ts +1 -0
  88. package/src/errors/write-error-info.ts +6 -2
  89. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  90. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  91. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  92. package/src/event-store/admin-api.ts +11 -4
  93. package/src/event-store/event-store.ts +19 -4
  94. package/src/event-store/types.ts +1 -1
  95. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  96. package/src/files/__tests__/local-provider.test.ts +31 -0
  97. package/src/files/__tests__/write-stream.test.ts +3 -3
  98. package/src/files/index.ts +1 -1
  99. package/src/files/local-provider.ts +6 -1
  100. package/src/files/types.ts +8 -1
  101. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  102. package/src/jobs/job-runner.ts +41 -11
  103. package/src/logging/types.ts +1 -1
  104. package/src/observability/index.ts +1 -0
  105. package/src/observability/standard-metrics.ts +35 -2
  106. package/src/observability/types/index.ts +1 -1
  107. package/src/observability/types/metric.ts +1 -1
  108. package/src/observability/types/provider.ts +1 -1
  109. package/src/observability/types/span.ts +1 -1
  110. package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
  111. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  112. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  113. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  114. package/src/pipeline/dispatch-shared.ts +59 -6
  115. package/src/pipeline/dispatch-stream.ts +32 -11
  116. package/src/pipeline/dispatcher.ts +7 -1
  117. package/src/pipeline/event-consumer-state.ts +16 -13
  118. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  119. package/src/pipeline/event-dispatcher.ts +22 -0
  120. package/src/pipeline/index.ts +2 -0
  121. package/src/pipeline/system-hooks.ts +87 -0
  122. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  123. package/src/rate-limit/resolver.ts +6 -2
  124. package/src/schema-cli.ts +24 -12
  125. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  126. package/src/search/reindex-entity.ts +31 -2
  127. package/src/search/types.ts +1 -1
  128. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  129. package/src/stack/db.ts +2 -1
  130. package/src/stack/push-entity-projection-tables.ts +2 -1
  131. package/src/stack/request-helper.ts +20 -1
  132. package/src/stack/table-helpers.ts +6 -4
  133. package/src/stack/test-stack.ts +18 -15
  134. package/src/testing/__tests__/late-bound.test.ts +7 -0
  135. package/src/testing/__tests__/wait-for.test.ts +6 -0
  136. package/src/testing/file-provider-contract.ts +26 -6
  137. package/src/testing/index.ts +1 -0
  138. package/src/testing/late-bound.ts +5 -3
  139. package/src/testing/wait-for.ts +3 -0
  140. package/src/testing/without-ambient-temporal.ts +14 -0
  141. package/src/time/geo-tz.ts +1 -1
  142. package/src/time/polyfill.ts +21 -38
  143. package/src/time/tz-context.ts +37 -31
  144. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  145. package/src/utils/safe-json.ts +13 -1
  146. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -19,6 +19,7 @@
19
19
  // drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
20
20
  // reference, NICHT als runtime-API-call)
21
21
 
22
+ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
22
23
  import type {
23
24
  SelectOptions,
24
25
  WhereObject,
@@ -51,7 +52,6 @@ import type { BunDbRunner } from "./connection";
51
52
  // enumerable props, so an entity field named `source`/`columns`/`tableName`/…
52
53
  // would overwrite the matching meta key — reading the meta from the symbol is
53
54
  // the only collision-safe path.
54
- const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
55
55
 
56
56
  function isEntityTableMeta(v: unknown): v is EntityTableMeta {
57
57
  return (
@@ -199,14 +199,23 @@ export type {
199
199
  WhereValue,
200
200
  } from "@cosmicdrift/kumiko-types/where-clause-types";
201
201
 
202
+ const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like"] as const;
203
+ // Forces a tsc error here (not just a silent runtime miss) the moment
204
+ // WhereOperator in @cosmicdrift/kumiko-types gains a key this array doesn't
205
+ // know about — the two live in different packages and can't share a value
206
+ // import without adding a runtime dependency edge.
207
+ type _WhereOperatorKeysExhaustive =
208
+ Exclude<keyof WhereOperator, (typeof WHERE_OPERATOR_KEYS)[number]> extends never ? true : never;
209
+ const _whereOperatorKeysExhaustive: _WhereOperatorKeysExhaustive = true;
210
+ void _whereOperatorKeysExhaustive;
211
+
202
212
  function isWhereOperator(v: unknown): v is WhereOperator {
203
213
  if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
204
214
  // Don't false-positive on Date / Temporal / other domain-objects.
205
215
  // WhereOperator is plain object literal with at most these keys.
206
216
  const keys = Object.keys(v);
207
217
  if (keys.length === 0) return false;
208
- const opKeys = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
209
- return keys.every((k) => opKeys.includes(k));
218
+ return keys.every((k) => (WHERE_OPERATOR_KEYS as readonly string[]).includes(k));
210
219
  }
211
220
  // Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
212
221
  // biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
@@ -6,7 +6,8 @@
6
6
  // out) entry point, own DB connection) so `kumiko-consumer` ships the same
7
7
  // way `kumiko-schema` does.
8
8
 
9
- import { createDbConnection } from "./db";
9
+ import { createConnection } from "./db/api";
10
+ import { dbConnectionOptionsFromEnv } from "./db/connection";
10
11
  import { getConsumerState, restartConsumer } from "./pipeline";
11
12
  import { ensureTemporalPolyfill } from "./time";
12
13
 
@@ -15,9 +16,45 @@ export type ConsumerCliOut = {
15
16
  readonly err: (line: string) => void;
16
17
  };
17
18
 
18
- function parseInstanceIdFlag(argv: readonly string[]): string | undefined {
19
- const i = argv.indexOf("--instance-id");
20
- return i === -1 ? undefined : argv[i + 1];
19
+ type ParsedConsumerCliArgs = {
20
+ readonly sub: string | undefined;
21
+ readonly name: string | undefined;
22
+ readonly instanceId: string | undefined;
23
+ readonly error: string | undefined;
24
+ };
25
+
26
+ // Single pass: pulls --instance-id (both "--instance-id <id>" and
27
+ // "--instance-id=<id>") out of argv regardless of position, leaving the
28
+ // remaining positionals for sub/name — instead of two independent
29
+ // positional/flag reads that silently misparse "--instance-id" with no
30
+ // value, the "=" form, or a flag placed before the positionals (#1412).
31
+ function parseConsumerCliArgs(argv: readonly string[]): ParsedConsumerCliArgs {
32
+ const positionals: string[] = [];
33
+ let instanceId: string | undefined;
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const arg = argv[i];
36
+ if (arg === undefined) continue;
37
+ if (arg === "--instance-id") {
38
+ const value = argv[i + 1];
39
+ if (value === undefined || value.startsWith("--")) {
40
+ return {
41
+ sub: undefined,
42
+ name: undefined,
43
+ instanceId: undefined,
44
+ error: "--instance-id braucht einen Wert.",
45
+ };
46
+ }
47
+ instanceId = value;
48
+ i++;
49
+ continue;
50
+ }
51
+ if (arg.startsWith("--instance-id=")) {
52
+ instanceId = arg.slice("--instance-id=".length);
53
+ continue;
54
+ }
55
+ positionals.push(arg);
56
+ }
57
+ return { sub: positionals[0], name: positionals[1], instanceId, error: undefined };
21
58
  }
22
59
 
23
60
  export async function runConsumerCli(
@@ -29,30 +66,40 @@ export async function runConsumerCli(
29
66
  // is a Temporal.Instant, so without this every subcommand throws "Temporal
30
67
  // is not defined" (same failure mode as schema-cli, see its polyfill test).
31
68
  await ensureTemporalPolyfill();
32
- const sub = argv[0];
69
+ const parsed = parseConsumerCliArgs(argv);
70
+ if (parsed.error) {
71
+ out.err(` ${parsed.error}`);
72
+ return 1;
73
+ }
74
+ const { sub, name, instanceId } = parsed;
33
75
 
34
76
  if (sub !== "status" && sub !== "restart") {
35
- out.log("");
36
- out.log(" Subcommands:");
37
- out.log(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
38
- out.log(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
39
- out.log("");
77
+ // Unknown subcommand must be visible on stderr, not just a bare exit 1 —
78
+ // ops invocations piping `2>&1 >/dev/null` or log pipelines that only
79
+ // watch stderr would otherwise see a failure with zero explanation
80
+ // (#1412). A bare help call (no subcommand at all) is not an error, so
81
+ // it keeps exit 0 + stdout.
82
+ const write = sub === undefined ? out.log : out.err;
83
+ if (sub !== undefined) out.err(` Unknown subcommand: ${sub}`);
84
+ write("");
85
+ write(" Subcommands:");
86
+ write(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
87
+ write(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
88
+ write("");
40
89
  return sub === undefined ? 0 : 1;
41
90
  }
42
91
 
43
- const name = argv[1];
44
92
  if (!name) {
45
93
  out.err(` Usage: consumer ${sub} <name> [--instance-id <id>]`);
46
94
  return 1;
47
95
  }
48
- const instanceId = parseInstanceIdFlag(argv);
49
96
 
50
97
  const dbUrl = process.env["DATABASE_URL"];
51
98
  if (!dbUrl) {
52
99
  out.err(" DATABASE_URL not set.");
53
100
  return 1;
54
101
  }
55
- const { db, close } = createDbConnection(dbUrl);
102
+ const { db, close } = await createConnection(dbUrl, dbConnectionOptionsFromEnv());
56
103
  try {
57
104
  if (sub === "status") {
58
105
  const state = await getConsumerState(db, name, instanceId);
@@ -121,7 +121,8 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
121
121
  test("restore: role without a write-rule → ownership_denied", async () => {
122
122
  const created = await crud.create({ email: "torestore@test.de" }, admin, tdb);
123
123
  if (!created.isSuccess) throw new Error("setup failed");
124
- await crud.delete({ id: created.data.id }, admin, tdb);
124
+ const deleted = await crud.delete({ id: created.data.id }, admin, tdb);
125
+ if (!deleted.isSuccess) throw new Error("setup failed: delete");
125
126
 
126
127
  const result = await crud.restore({ id: created.data.id }, nonAdmin, tdb);
127
128
  expect(result.isSuccess).toBe(false);
@@ -359,6 +360,18 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
359
360
  expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
360
361
  1,
361
362
  );
363
+
364
+ // Guard against the known Bun.SQL pooled-connection-poisoning class: the
365
+ // losing update above provoked a real 23505 unique-violation on the
366
+ // shared pool. The two tests below this one reuse `tdb`/`testDb.db`, so
367
+ // a poisoned connection would surface as their unrelated queries
368
+ // failing — a trivial round-trip here catches that immediately instead
369
+ // of leaving it to whichever later test happens to hit the bad
370
+ // connection.
371
+ const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
372
+ ok: number;
373
+ }>;
374
+ expect(healthCheck[0]?.ok).toBe(1);
362
375
  });
363
376
 
364
377
  test("forget with entityCache clears the cache entry", async () => {
@@ -103,6 +103,25 @@ describe("flattenLocatedTimestamp — Insert/Update Convert", () => {
103
103
  });
104
104
  });
105
105
 
106
+ describe("rehydrateLocatedTimestamp — kumiko-framework#1490: no ambient Temporal global", () => {
107
+ test("rehydrates without relying on globalThis.Temporal", () => {
108
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
109
+ delete (globalThis as { Temporal?: unknown }).Temporal;
110
+ try {
111
+ const out = rehydrateLocatedTimestamp(
112
+ { pickupUtc: "2026-04-15T09:00:00Z", pickupTz: "Europe/Lisbon" },
113
+ orderEntity,
114
+ );
115
+ expect(out).toEqual({
116
+ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
117
+ });
118
+ } finally {
119
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
120
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
121
+ }
122
+ });
123
+ });
124
+
106
125
  describe("rehydrateLocatedTimestamp — Read Convert", () => {
107
126
  test("{ <name>Utc, <name>Tz } DB-Form → { at, tz, utc } API-Form (Pickup-Ort-lokal)", () => {
108
127
  const out = rehydrateLocatedTimestamp(
@@ -16,4 +16,65 @@ describe("splitSqlStatements", () => {
16
16
  test("filters empty segments", () => {
17
17
  expect(splitSqlStatements("-- only comments\n; ;")).toEqual([]);
18
18
  });
19
+
20
+ test("does not split mid-statement on a semicolon inside a line comment", () => {
21
+ const sql = `
22
+ -- sets default on the entity; the create/update handlers fill it
23
+ CREATE TABLE "a" ("id" uuid);
24
+ `;
25
+ expect(splitSqlStatements(sql)).toEqual(['CREATE TABLE "a" ("id" uuid);']);
26
+ });
27
+
28
+ test("does not split on a semicolon inside a block comment", () => {
29
+ const sql = `
30
+ /* multi
31
+ line; with semi */
32
+ CREATE TABLE "a" ("id" uuid);
33
+ `;
34
+ expect(splitSqlStatements(sql)).toEqual(['CREATE TABLE "a" ("id" uuid);']);
35
+ });
36
+
37
+ test("a block-comment opener inside a line comment does not swallow the next statement", () => {
38
+ const sql = `
39
+ -- note: see /* details below
40
+ CREATE TABLE "a" ("id" uuid);
41
+ /* real block comment */
42
+ CREATE TABLE "b" ("id" uuid);
43
+ `;
44
+ expect(splitSqlStatements(sql)).toEqual([
45
+ 'CREATE TABLE "a" ("id" uuid);',
46
+ 'CREATE TABLE "b" ("id" uuid);',
47
+ ]);
48
+ });
49
+
50
+ test("does not split on a semicolon inside a single-quoted string literal", () => {
51
+ const sql = `INSERT INTO "a" ("v") VALUES ('a;b');`;
52
+ expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a;b');`]);
53
+ });
54
+
55
+ test("does not treat a double-dash inside a string literal as a comment", () => {
56
+ const sql = `INSERT INTO "a" ("v") VALUES ('a--b');`;
57
+ expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a--b');`]);
58
+ });
59
+
60
+ test("handles an escaped quote inside a single-quoted string literal ('')", () => {
61
+ const sql = `INSERT INTO "a" ("v") VALUES ('a'';b');`;
62
+ expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a'';b');`]);
63
+ });
64
+
65
+ test("does not split on a semicolon inside a double-quoted identifier", () => {
66
+ const sql = `CREATE TABLE "weird;name" ("id" uuid);`;
67
+ expect(splitSqlStatements(sql)).toEqual([`CREATE TABLE "weird;name" ("id" uuid);`]);
68
+ });
69
+
70
+ test("throws fail-loud on an unterminated block comment instead of silently dropping statements", () => {
71
+ const sql = `/* oops\nCREATE TABLE "a" ("id" uuid);`;
72
+ expect(() => splitSqlStatements(sql)).toThrow();
73
+ });
74
+
75
+ test("a trailing line comment without a newline terminates cleanly", () => {
76
+ expect(splitSqlStatements('CREATE TABLE "a" ("id" uuid);\n-- done')).toEqual([
77
+ 'CREATE TABLE "a" ("id" uuid);',
78
+ ]);
79
+ });
19
80
  });
@@ -136,7 +136,35 @@ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("te
136
136
  }
137
137
  });
138
138
 
139
- test("commented-out destructive DROP TABLE is not replayed", () => {
139
+ // The real migrate-runner never executes a DESTRUCTIVE marker (it's a
140
+ // commented-out suggestion an operator uncomments deliberately) — but the
141
+ // snapshot already reflects the post-drop state as the intended end state,
142
+ // so replay must count the marker as applied or "unexpected-table"/
143
+ // "unexpected columns" fires forever against a table the snapshot
144
+ // correctly omits.
145
+ // #1473/2: CREATE TABLE IF NOT EXISTS is a no-op in real Postgres when
146
+ // the table already exists — treating it as an overwrite here silently
147
+ // discards whatever an in-between ALTER TABLE added, exactly the
148
+ // copy-paste-migration class this replay exists to catch.
149
+ test("CREATE TABLE IF NOT EXISTS for an already-created table is a no-op, not an overwrite", () => {
150
+ const dir = tmpMigrationsDir();
151
+ try {
152
+ write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
153
+ write(dir, "0002_add-col.sql", `ALTER TABLE "read_a" ADD COLUMN "title" text;`);
154
+ // Accidental copy-paste of 0001 — same CREATE TABLE IF NOT EXISTS text.
155
+ write(
156
+ dir,
157
+ "0003_copy-paste.sql",
158
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`,
159
+ );
160
+ const replayed = replayMigrationsDir(dir);
161
+ expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
162
+ } finally {
163
+ rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("commented-out destructive DROP TABLE is replayed as applied", () => {
140
168
  const dir = tmpMigrationsDir();
141
169
  try {
142
170
  write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
@@ -146,7 +174,27 @@ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("te
146
174
  `-- DESTRUCTIVE: DROP TABLE IF EXISTS "read_a"; -- uncomment + ensure backup`,
147
175
  );
148
176
  const replayed = replayMigrationsDir(dir);
149
- expect([...replayed.keys()]).toEqual(["read_a"]);
177
+ expect([...replayed.keys()]).toEqual([]);
178
+ } finally {
179
+ rmSync(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test("commented-out destructive DROP COLUMN is replayed as applied", () => {
184
+ const dir = tmpMigrationsDir();
185
+ try {
186
+ write(
187
+ dir,
188
+ "0001_init.sql",
189
+ `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "legacy" text);`,
190
+ );
191
+ write(
192
+ dir,
193
+ "0002_drop-col.sql",
194
+ `-- DESTRUCTIVE: ALTER TABLE "read_a" DROP COLUMN "legacy"; -- uncomment + ensure backup`,
195
+ );
196
+ const replayed = replayMigrationsDir(dir);
197
+ expect([...(replayed.get("read_a")?.columns ?? [])]).toEqual(["id"]);
150
198
  } finally {
151
199
  rmSync(dir, { recursive: true, force: true });
152
200
  }
@@ -236,6 +284,87 @@ describe("diffReplayAgainstSnapshot", () => {
236
284
  }
237
285
  });
238
286
 
287
+ test("kumiko-framework#1473: an unparsed ALTER TABLE RENAME statement fails loud instead of vanishing", () => {
288
+ const dir = tmpMigrationsDir();
289
+ try {
290
+ write(
291
+ dir,
292
+ "0001_init.sql",
293
+ `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY);
294
+ ALTER TABLE "widgets" RENAME TO "gadgets";`,
295
+ );
296
+ expect(() => replayMigrationsDir(dir)).toThrow(/unparsed table-DDL statement/);
297
+ } finally {
298
+ rmSync(dir, { recursive: true, force: true });
299
+ }
300
+ });
301
+
302
+ test("kumiko-framework#1535: an unquoted CREATE TABLE identifier parses instead of failing loud", () => {
303
+ // Was "fails loud" pre-#1535 — but real committed migrations do this
304
+ // (publicstatus's 0014_marketing-waitlist.sql), and the old behavior
305
+ // turned an already-graceful missing-table report into a hard abort.
306
+ // Unquoted identifiers must parse like quoted ones.
307
+ const dir = tmpMigrationsDir();
308
+ try {
309
+ write(dir, "0001_init.sql", `CREATE TABLE widgets ("id" uuid PRIMARY KEY);`);
310
+ const replayed = replayMigrationsDir(dir);
311
+ expect([...replayed.keys()]).toEqual(["widgets"]);
312
+ expect([...(replayed.get("widgets")?.columns ?? [])]).toEqual(["id"]);
313
+ } finally {
314
+ rmSync(dir, { recursive: true, force: true });
315
+ }
316
+ });
317
+
318
+ test("schema-qualified CREATE TABLE (public.widgets) fails loud — IDENT rejects '.'", () => {
319
+ const dir = tmpMigrationsDir();
320
+ try {
321
+ write(dir, "0001_init.sql", "CREATE TABLE public.widgets (id uuid PRIMARY KEY);\n");
322
+ expect(() => replayMigrationsDir(dir)).toThrow(/unparsed table-DDL statement/);
323
+ } finally {
324
+ rmSync(dir, { recursive: true, force: true });
325
+ }
326
+ });
327
+
328
+ test("kumiko-framework#1535: a bare table-level PRIMARY KEY/UNIQUE clause isn't parsed as a column", () => {
329
+ const dir = tmpMigrationsDir();
330
+ try {
331
+ write(
332
+ dir,
333
+ "0001_init.sql",
334
+ `CREATE TABLE widgets (id uuid, sku text, name text, UNIQUE (sku), PRIMARY KEY (id));`,
335
+ );
336
+ const replayed = replayMigrationsDir(dir);
337
+ expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "name", "sku"]);
338
+ } finally {
339
+ rmSync(dir, { recursive: true, force: true });
340
+ }
341
+ });
342
+
343
+ test("kumiko-framework#1535: shape-neutral ALTER COLUMN/CONSTRAINT clauses don't fail loud", () => {
344
+ // Regression for the false-positive class: these are all real,
345
+ // committed, shape-neutral DDL shapes (kumiko-studio's
346
+ // 0002_file_refs_soft_delete.sql, publicstatus's
347
+ // 0020_add-monitor-checks.sql) that the #1535 fix must recognize
348
+ // instead of throwing "unparsed table-DDL statement".
349
+ const dir = tmpMigrationsDir();
350
+ try {
351
+ write(
352
+ dir,
353
+ "0001_init.sql",
354
+ `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY, "title" text);
355
+ ALTER TABLE "widgets" ALTER COLUMN "title" SET NOT NULL;
356
+ ALTER TABLE "widgets" ALTER COLUMN "title" SET DEFAULT 'untitled';
357
+ ALTER TABLE "widgets" ADD CONSTRAINT "widgets_title_check" CHECK (length("title") > 0);
358
+ ALTER TABLE "widgets" ENABLE ROW LEVEL SECURITY;
359
+ ALTER TABLE "widgets" OWNER TO app_user;`,
360
+ );
361
+ const replayed = replayMigrationsDir(dir);
362
+ expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "title"]);
363
+ } finally {
364
+ rmSync(dir, { recursive: true, force: true });
365
+ }
366
+ });
367
+
239
368
  test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
240
369
  const dir = tmpMigrationsDir();
241
370
  try {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
+ import type { DbRunner } from "../connection";
4
5
  import type { TableColumns } from "../dialect";
5
6
  import { buildEntityTableMeta } from "../entity-table-meta";
6
7
  import { buildEntityTable } from "../table-builder";
@@ -24,13 +25,16 @@ const foreign = testTenantId(2);
24
25
 
25
26
  type Captured = { sql: string; values: readonly unknown[] };
26
27
 
27
- function recordingDb(captured: Captured[]) {
28
+ function recordingDb(captured: Captured[]): DbRunner {
28
29
  return {
29
30
  unsafe: async (sql: string, values: readonly unknown[]) => {
30
31
  captured.push({ sql, values });
31
32
  return [] as unknown[];
32
33
  },
33
- };
34
+ begin: async () => {
35
+ throw new Error("begin not used in these tests");
36
+ },
37
+ } as DbRunner;
34
38
  }
35
39
 
36
40
  describe("tenant-db WHERE merge — caller cannot override tenant scope", () => {
package/src/db/api.ts CHANGED
@@ -15,7 +15,7 @@ export type DbConnectionOptions = {
15
15
 
16
16
  // Connection-Handle: db für Queries, client für Legacy-Zugriff (LISTEN/NOTIFY-Peer
17
17
  // bei Bun.SQL), close für Pool-Shutdown.
18
- export type DbConnection = {
18
+ export type DbPoolHandle = {
19
19
  /** Provider Connection — Calls gehen über asRawClient() oder direkt. */
20
20
  // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection — postgres-js | Bun.SQL
21
21
  readonly db: any;
@@ -34,7 +34,7 @@ let _provider: undefined | (() => Promise<typeof import("./postgres-provider")>)
34
34
  export async function createConnection(
35
35
  url: string,
36
36
  options: DbConnectionOptions = {},
37
- ): Promise<DbConnection> {
37
+ ): Promise<DbPoolHandle> {
38
38
  const p = process.env["DB_PROVIDER"];
39
39
  if (p === "bun" || p === "bun-sql") {
40
40
  const { createBunConnection } = await import("./bun-provider");
@@ -8,9 +8,9 @@
8
8
  // Bun.SQL hat kein LISTEN — postgres-js-Peer für event-dispatcher.
9
9
 
10
10
  import postgres from "postgres";
11
- import type { DbConnection, DbConnectionOptions } from "./api";
11
+ import type { DbConnectionOptions, DbPoolHandle } from "./api";
12
12
 
13
- export function createBunConnection(url: string, options: DbConnectionOptions = {}): DbConnection {
13
+ export function createBunConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
14
14
  const bunOpts: { max?: number; idleTimeout?: number; connectionTimeout?: number } = {};
15
15
  if (options.maxConnections !== undefined) bunOpts.max = options.maxConnections;
16
16
  if (options.idleTimeoutSeconds !== undefined) bunOpts.idleTimeout = options.idleTimeoutSeconds;
@@ -4,9 +4,12 @@ import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connec
4
4
  import postgres from "postgres";
5
5
  import { readPositiveIntEnv } from "../utils/env-parse";
6
6
 
7
- // Legacy Types für Aufrufer die direkt diese Module importieren
8
- export * from "@cosmicdrift/kumiko-types/db-connection";
9
- export { createConnection, type DbConnectionOptions } from "./api";
7
+ // Raw client types (postgres-js | Bun.SQL) the name used across query/
8
+ // event-store/pipeline call sites. The structural pool handle from ./api is
9
+ // `DbPoolHandle` (createConnection's return type) to avoid colliding with this.
10
+ export type * from "@cosmicdrift/kumiko-types/db-connection";
11
+ export type { DbConnectionOptions, DbPoolHandle } from "./api";
12
+ export { createConnection } from "./api";
10
13
 
11
14
  // Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
12
15
  // guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
package/src/db/dialect.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import {
20
20
  type ColumnHandle,
21
21
  KUMIKO_COLUMNS_SYMBOL,
22
+ KUMIKO_META_SYMBOL,
22
23
  KUMIKO_NAME_SYMBOL,
23
24
  type SchemaTable,
24
25
  } from "@cosmicdrift/kumiko-types/schema-table-types";
@@ -41,12 +42,6 @@ export type TableColumns<_T = any> = SchemaTable;
41
42
  // biome-ignore lint/suspicious/noExplicitAny: legacy type — chain API is gone
42
43
  export type SelectQuery = any;
43
44
 
44
- // Shadow-proof handle on the EntityTableMeta. The column handles below are
45
- // spread as enumerable props, so an entity field named `source`/`columns`/
46
- // `tableName`/… would overwrite the matching meta key. extractTableInfo reads
47
- // the canonical meta from this symbol instead of the (shadowable) props.
48
- const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
49
-
50
45
  function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
51
46
  return t.startsWith("numeric(");
52
47
  }
@@ -1,2 +1,2 @@
1
1
  // Legacy path — re-exported for callers still importing this module directly.
2
- export * from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
+ export type * from "@cosmicdrift/kumiko-types/entity-table-meta-types";
@@ -1,3 +1,4 @@
1
+ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
1
2
  import { requestContext } from "../api/request-context";
2
3
  import {
3
4
  collectPiiSubjectFields,
@@ -359,9 +360,7 @@ export function buildExecutorContext(
359
360
  }
360
361
  // ownership has raw SQL — splice it into a raw query alongside the
361
362
  // idFilter + tenant-filter that TenantDb would have added.
362
- const tableName = String(
363
- (table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
364
- );
363
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
365
364
  const colSql = (field: string): string =>
366
365
  `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
367
366
  const whereParts: string[] = [];
@@ -1,3 +1,4 @@
1
+ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
1
2
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
2
3
  import { executeRawQuery } from "../db/queries/raw-sql";
3
4
  import { coerceRow, extractTableInfo } from "../db/query";
@@ -66,9 +67,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
66
67
  // Build the WHERE clause as raw SQL — ownership produces a
67
68
  // parameterised fragment that we splice in alongside simple WhereObject
68
69
  // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
69
- const tableName = String(
70
- (table as unknown as Record<symbol, unknown>)[Symbol.for("kumiko:schema:Name")],
71
- );
70
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
72
71
  const whereSql: string[] = [];
73
72
  const params: unknown[] = [];
74
73
  const colSql = (field: string): string =>
@@ -3,6 +3,7 @@ import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership
3
3
  import type { EntityId } from "../engine/types";
4
4
  import {
5
5
  VersionConflictError as FrameworkVersionConflict,
6
+ IdempotentReplayError,
6
7
  InternalError,
7
8
  NotFoundError,
8
9
  UnprocessableError,
@@ -10,6 +11,7 @@ import {
10
11
  } from "../errors";
11
12
  import {
12
13
  append,
14
+ IdempotentAppendConflictError as EventStoreIdempotentAppendConflict,
13
15
  VersionConflictError as EventStoreVersionConflict,
14
16
  getStreamVersion,
15
17
  } from "../event-store";
@@ -160,6 +162,9 @@ export function createWriteVerbs(
160
162
  }),
161
163
  );
162
164
  }
165
+ if (e instanceof EventStoreIdempotentAppendConflict) {
166
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
167
+ }
163
168
  throw e;
164
169
  }
165
170
 
@@ -390,6 +395,9 @@ export function createWriteVerbs(
390
395
  }),
391
396
  );
392
397
  }
398
+ if (e instanceof EventStoreIdempotentAppendConflict) {
399
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
400
+ }
393
401
  throw e;
394
402
  }
395
403
  },
package/src/db/index.ts CHANGED
@@ -3,7 +3,14 @@ export { nullBlindIndexesForSubject } from "./blind-index-cleanup";
3
3
  export { collectTableMetas } from "./collect-table-metas";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
5
5
  export { seedConfigValues } from "./config-seed";
6
- export type { DbConnection, DbConnectionOptions, DbRow, DbRunner, DbTx } from "./connection";
6
+ export type {
7
+ DbConnection,
8
+ DbConnectionOptions,
9
+ DbPoolHandle,
10
+ DbRow,
11
+ DbRunner,
12
+ DbTx,
13
+ } from "./connection";
7
14
  export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection";
8
15
  export type { CursorQueryOptions, CursorResult } from "./cursor";
9
16
  export { decodeCursor, encodeCursor } from "./cursor";
@@ -9,6 +9,10 @@
9
9
  // gespeicherter tz). Server kennt User-TZ nicht — User-spezifische
10
10
  // Anzeige passiert client-seitig aus utc.
11
11
 
12
+ // Static import, not the ambient global: Bun doesn't expose Temporal on
13
+ // globalThis, so this crashed with "Temporal is not defined" outside boot
14
+ // paths that install it (#1480).
15
+ import { Temporal } from "temporal-polyfill";
12
16
  import type { EntityDefinition } from "../engine/types";
13
17
 
14
18
  // Sprint F: <name>Utc-Spalte ist jetzt instant() (siehe dialect.ts) —