@cosmicdrift/kumiko-framework 0.164.0 → 0.165.1

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 (160) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +62 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +326 -18
  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 +18 -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 +146 -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__/login-rate-limiter-sweep.test.ts +27 -18
  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 +117 -30
  20. package/src/api/server.ts +39 -7
  21. package/src/api/sse-broker.ts +39 -0
  22. package/src/bun-db/connection.ts +3 -3
  23. package/src/bun-db/index.ts +1 -0
  24. package/src/bun-db/query.ts +12 -3
  25. package/src/consumer-cli.ts +60 -13
  26. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +19 -13
  27. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  28. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  29. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  30. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  31. package/src/db/api.ts +2 -2
  32. package/src/db/bun-provider.ts +2 -2
  33. package/src/db/connection.ts +6 -3
  34. package/src/db/dialect.ts +1 -6
  35. package/src/db/entity-table-meta-types.ts +1 -1
  36. package/src/db/event-store-executor-context.ts +2 -3
  37. package/src/db/event-store-executor-read.ts +2 -3
  38. package/src/db/event-store-executor-write.ts +8 -0
  39. package/src/db/index.ts +8 -3
  40. package/src/db/located-timestamp.ts +4 -0
  41. package/src/db/migrate-runner.ts +107 -11
  42. package/src/db/pg-error.ts +8 -0
  43. package/src/db/postgres-provider.ts +2 -2
  44. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  45. package/src/db/queries/ddl.ts +45 -0
  46. package/src/db/queries/event-store.ts +97 -21
  47. package/src/db/queries/test-stack.ts +4 -30
  48. package/src/db/reference-data.ts +2 -3
  49. package/src/db/replay-migration-sql.ts +114 -12
  50. package/src/db/tenant-db.ts +2 -4
  51. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  52. package/src/engine/__tests__/engine.test.ts +30 -0
  53. package/src/engine/__tests__/registry.test.ts +36 -0
  54. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  55. package/src/engine/__tests__/store-table.test.ts +2 -2
  56. package/src/engine/boot-validator/entity-handler.ts +14 -25
  57. package/src/engine/boot-validator/nav.ts +5 -0
  58. package/src/engine/constants.ts +32 -6
  59. package/src/engine/create-app.ts +11 -0
  60. package/src/engine/effective-features.ts +12 -2
  61. package/src/engine/extensions/user-data.ts +12 -4
  62. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  63. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  64. package/src/engine/feature-ui-extensions.ts +2 -2
  65. package/src/engine/hook-helpers.ts +3 -1
  66. package/src/engine/index.ts +1 -1
  67. package/src/engine/ownership.ts +4 -3
  68. package/src/engine/registry-ingest.ts +14 -14
  69. package/src/engine/registry-state.ts +4 -1
  70. package/src/engine/registry-validate.ts +7 -2
  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/index.ts +0 -2
  85. package/src/engine/types/nav.ts +1 -1
  86. package/src/engine/types/ownership.ts +1 -1
  87. package/src/engine/types/projection.ts +1 -1
  88. package/src/engine/types/relations.ts +1 -1
  89. package/src/engine/types/screen.ts +1 -1
  90. package/src/engine/types/step.ts +1 -1
  91. package/src/engine/types/target-ref.ts +1 -1
  92. package/src/engine/types/tree-node.ts +1 -1
  93. package/src/engine/types/workspace.ts +1 -1
  94. package/src/engine/validate-projection-allowlist.ts +5 -5
  95. package/src/errors/classes.ts +21 -0
  96. package/src/errors/index.ts +1 -0
  97. package/src/errors/write-error-info.ts +6 -2
  98. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  99. package/src/event-store/__tests__/event-store.integration.test.ts +60 -14
  100. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +27 -12
  101. package/src/event-store/admin-api.ts +11 -4
  102. package/src/event-store/event-store.ts +20 -21
  103. package/src/event-store/index.ts +0 -1
  104. package/src/event-store/types.ts +1 -1
  105. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  106. package/src/files/__tests__/local-provider.test.ts +31 -0
  107. package/src/files/__tests__/write-stream.test.ts +3 -3
  108. package/src/files/index.ts +1 -1
  109. package/src/files/local-provider.ts +6 -1
  110. package/src/files/types.ts +8 -1
  111. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  112. package/src/jobs/job-runner.ts +41 -11
  113. package/src/logging/types.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 +203 -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__/job-trigger-consumer.integration.test.ts +106 -0
  124. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +111 -88
  125. package/src/pipeline/dispatch-shared.ts +59 -6
  126. package/src/pipeline/dispatch-stream.ts +44 -17
  127. package/src/pipeline/dispatcher.ts +7 -1
  128. package/src/pipeline/event-consumer-state.ts +16 -13
  129. package/src/pipeline/event-dispatcher-delivery.ts +22 -7
  130. package/src/pipeline/event-dispatcher.ts +22 -0
  131. package/src/pipeline/index.ts +2 -0
  132. package/src/pipeline/system-hooks.ts +137 -1
  133. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  134. package/src/rate-limit/resolver.ts +6 -2
  135. package/src/schema-cli.ts +24 -12
  136. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  137. package/src/search/reindex-entity.ts +31 -2
  138. package/src/search/types.ts +1 -1
  139. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  140. package/src/stack/db.ts +2 -1
  141. package/src/stack/push-entity-projection-tables.ts +2 -1
  142. package/src/stack/request-helper.ts +20 -1
  143. package/src/stack/table-helpers.ts +6 -4
  144. package/src/stack/test-stack.ts +18 -15
  145. package/src/testing/__tests__/late-bound.test.ts +7 -0
  146. package/src/testing/__tests__/wait-for.test.ts +6 -0
  147. package/src/testing/file-provider-contract.ts +26 -6
  148. package/src/testing/index.ts +1 -0
  149. package/src/testing/late-bound.ts +5 -3
  150. package/src/testing/wait-for.ts +3 -0
  151. package/src/testing/without-ambient-temporal.ts +14 -0
  152. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  153. package/src/time/geo-tz.ts +1 -1
  154. package/src/time/polyfill.ts +28 -39
  155. package/src/time/tz-context.ts +30 -24
  156. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  157. package/src/utils/safe-json.ts +3 -2
  158. package/src/db/__tests__/encryption.test.ts +0 -39
  159. package/src/db/encryption.ts +0 -45
  160. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -1,3 +1,4 @@
1
+ import { userAccessChannel } from "../engine/constants";
1
2
  import { generateId } from "../utils";
2
3
 
3
4
  export type SseClient = {
@@ -17,10 +18,18 @@ export type SseBroker = {
17
18
  pushToChannel(channel: string, event: SseEvent): void;
18
19
  getClientCount(channel: string): number;
19
20
  getTotalClientCount(): number;
21
+ // Internal (non-SSE-client) subscription, e.g. dispatch-stream watching
22
+ // for mid-stream access revocation. Kept separate from addClient/
23
+ // pushToChannel: those count towards getClientCount/getTotalClientCount
24
+ // (real SSE connections) and their send/close shape doesn't fit a plain
25
+ // callback listener. Returns an unsubscribe function.
26
+ subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
27
+ publishAccessInvalidation(userId: string): void;
20
28
  };
21
29
 
22
30
  export function createSseBroker(): SseBroker {
23
31
  const channels = new Map<string, Map<string, SseClient>>();
32
+ const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
24
33
 
25
34
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
26
35
  let clients = channels.get(channel);
@@ -67,5 +76,35 @@ export function createSseBroker(): SseBroker {
67
76
  }
68
77
  return total;
69
78
  },
79
+
80
+ subscribeAccessInvalidation(userId, onInvalidate) {
81
+ const channel = userAccessChannel(userId);
82
+ const listenerId = generateId();
83
+ let listeners = accessInvalidationListeners.get(channel);
84
+ if (!listeners) {
85
+ listeners = new Map();
86
+ accessInvalidationListeners.set(channel, listeners);
87
+ }
88
+ listeners.set(listenerId, onInvalidate);
89
+ return () => {
90
+ const current = accessInvalidationListeners.get(channel);
91
+ // skip: already unsubscribed (e.g. stream ended after a publish already fired)
92
+ if (!current) return;
93
+ current.delete(listenerId);
94
+ if (current.size === 0) accessInvalidationListeners.delete(channel);
95
+ };
96
+ },
97
+
98
+ publishAccessInvalidation(userId) {
99
+ const channel = userAccessChannel(userId);
100
+ const listeners = accessInvalidationListeners.get(channel);
101
+ // skip: no live stream is watching this user right now
102
+ if (!listeners) return;
103
+ // Snapshot before iterating — a fired listener unsubscribes itself,
104
+ // which would mutate `listeners` mid-iteration otherwise.
105
+ for (const onInvalidate of [...listeners.values()]) {
106
+ onInvalidate();
107
+ }
108
+ },
70
109
  };
71
110
  }
@@ -6,9 +6,12 @@
6
6
  // event-dispatcher.ts. Bun.sql 1.2.20 hat kein listen() (PR oven-sh/bun#25511
7
7
  // pending). Nach Landung des Bun-LISTEN-Supports: peer raus.
8
8
 
9
+ import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
9
10
  import postgres from "postgres";
10
11
  import { readPositiveIntEnv } from "../utils/env-parse";
11
12
 
13
+ export type { PgListenClient };
14
+
12
15
  // Bun.SQL ist callable als tagged template `sql\`...\`` PLUS hat methods
13
16
  // (.begin / .unsafe / .end / .file / .reserve etc.). DbConnection-Type
14
17
  // reflektiert die Instance-Shape.
@@ -21,9 +24,6 @@ export type BunDbTx = BunDbConnection;
21
24
  // Beide austauschbar im normalen call-path.
22
25
  export type BunDbRunner = BunDbConnection | BunDbTx;
23
26
 
24
- // Postgres-js peer NUR für event-dispatcher LISTEN.
25
- export type PgListenClient = ReturnType<typeof postgres>;
26
-
27
27
  export type BunDbConnectionOptions = {
28
28
  readonly maxConnections?: number;
29
29
  readonly idleTimeoutSeconds?: number;
@@ -11,6 +11,7 @@ export type {
11
11
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection";
12
12
  export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query";
13
13
  export {
14
+ asEntityTableMeta,
14
15
  asRawClient,
15
16
  countWhere,
16
17
  type DeleteManyBatchedOptions,
@@ -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);
@@ -50,7 +50,7 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
50
50
  });
51
51
 
52
52
  beforeAll(async () => {
53
- await unsafeCreateEntityTableFor(restrictedEntity, "esWriteRestricted");
53
+ await unsafeCreateEntityTable(testDb.db, restrictedEntity, "esWriteRestricted");
54
54
  });
55
55
 
56
56
  beforeEach(async () => {
@@ -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);
@@ -158,7 +159,7 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
158
159
  });
159
160
 
160
161
  beforeAll(async () => {
161
- await unsafeCreateEntityTableFor(hardDeleteEntity, "esWriteHard");
162
+ await unsafeCreateEntityTable(testDb.db, hardDeleteEntity, "esWriteHard");
162
163
  });
163
164
 
164
165
  beforeEach(async () => {
@@ -199,7 +200,7 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
199
200
  });
200
201
 
201
202
  beforeAll(async () => {
202
- await unsafeCreateEntityTableFor(ownedFieldEntity, "esWriteOwnedField");
203
+ await unsafeCreateEntityTable(testDb.db, ownedFieldEntity, "esWriteOwnedField");
203
204
  });
204
205
 
205
206
  beforeEach(async () => {
@@ -264,7 +265,7 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
264
265
  });
265
266
 
266
267
  beforeAll(async () => {
267
- await unsafeCreateEntityTableFor(versionEntity, "esWriteVersion");
268
+ await unsafeCreateEntityTable(testDb.db, versionEntity, "esWriteVersion");
268
269
  });
269
270
 
270
271
  beforeEach(async () => {
@@ -298,13 +299,6 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
298
299
  });
299
300
  });
300
301
 
301
- async function unsafeCreateEntityTableFor(
302
- entity: Parameters<typeof buildEntityTable>[1],
303
- name: string,
304
- ): Promise<void> {
305
- await unsafeCreateEntityTable(testDb.db, entity, name);
306
- }
307
-
308
302
  // =============================================================================
309
303
  // Concurrent update race → EventStoreVersionConflict catch + entityCache.del
310
304
  // on forget/restore (create/update/delete already exercise cache in the
@@ -341,7 +335,7 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
341
335
  });
342
336
 
343
337
  beforeAll(async () => {
344
- await unsafeCreateEntityTableFor(raceEntity, "esWriteRace");
338
+ await unsafeCreateEntityTable(testDb.db, raceEntity, "esWriteRace");
345
339
  });
346
340
 
347
341
  beforeEach(async () => {
@@ -366,6 +360,18 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
366
360
  expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
367
361
  1,
368
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);
369
375
  });
370
376
 
371
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)