@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
@@ -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,17 @@ export type SseBroker = {
17
18
  pushToChannel(channel: string, event: SseEvent): void;
18
19
  getClientCount(channel: string): number;
19
20
  getTotalClientCount(): number;
21
+ // Separate from addClient so it doesn't count towards getClientCount.
22
+ subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
23
+ publishAccessInvalidation(userId: string): void;
20
24
  };
21
25
 
22
26
  export function createSseBroker(): SseBroker {
27
+ // ponytail: in-process only — publishAccessInvalidation does not fan out via
28
+ // Redis. Multi-replica deployments will not revoke SSE streams on other pods
29
+ // (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
23
30
  const channels = new Map<string, Map<string, SseClient>>();
31
+ const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
24
32
 
25
33
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
26
34
  let clients = channels.get(channel);
@@ -67,5 +75,35 @@ export function createSseBroker(): SseBroker {
67
75
  }
68
76
  return total;
69
77
  },
78
+
79
+ subscribeAccessInvalidation(userId, onInvalidate) {
80
+ const channel = userAccessChannel(userId);
81
+ const listenerId = generateId();
82
+ let listeners = accessInvalidationListeners.get(channel);
83
+ if (!listeners) {
84
+ listeners = new Map();
85
+ accessInvalidationListeners.set(channel, listeners);
86
+ }
87
+ listeners.set(listenerId, onInvalidate);
88
+ return () => {
89
+ const current = accessInvalidationListeners.get(channel);
90
+ // skip: already unsubscribed (e.g. stream ended after a publish already fired)
91
+ if (!current) return;
92
+ current.delete(listenerId);
93
+ if (current.size === 0) accessInvalidationListeners.delete(channel);
94
+ };
95
+ },
96
+
97
+ publishAccessInvalidation(userId) {
98
+ const channel = userAccessChannel(userId);
99
+ const listeners = accessInvalidationListeners.get(channel);
100
+ // skip: no live stream is watching this user right now
101
+ if (!listeners) return;
102
+ // Snapshot before iterating — a fired listener unsubscribes itself,
103
+ // which would mutate `listeners` mid-iteration otherwise.
104
+ for (const onInvalidate of [...listeners.values()]) {
105
+ onInvalidate();
106
+ }
107
+ },
70
108
  };
71
109
  }
@@ -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 (
@@ -67,7 +67,7 @@ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
67
67
  // Resolve any framework table input to its canonical EntityTableMeta:
68
68
  // - table()/buildEntityTable outputs carry it under KUMIKO_META_SYMBOL, immune
69
69
  // to a column-handle shadowing a meta key.
70
- // - buildEntityTableMeta / defineUnmanagedTable return a plain meta with no
70
+ // - deriveEntityTableMeta / defineUnmanagedTable return a plain meta with no
71
71
  // handle-spread, so its structural shape is itself unshadowable.
72
72
  export function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
73
73
  if (table === null || typeof table !== "object") return undefined;
@@ -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
@@ -243,7 +252,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
243
252
  if (!meta) {
244
253
  throw new Error(
245
254
  "bun-db.extractTableInfo: table is not a kumiko EntityTableMeta — " +
246
- "build it via buildEntityTable / buildEntityTableMeta / table().",
255
+ "build it via buildEntityTable / deriveEntityTableMeta / table().",
247
256
  );
248
257
  }
249
258
  const colByField = new Map<string, string>();
@@ -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);
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDecimalField, createEntity } from "../../engine/factories";
3
3
  import { fieldToZod } from "../../engine/schema-builder";
4
- import { buildEntityTableMeta } from "../entity-table-meta";
4
+ import { deriveEntityTableMeta } from "../entity-table-meta";
5
5
  import { coerceRow, extractTableInfo } from "../query";
6
6
  import { renderTableDdl } from "../render-ddl";
7
7
  import { buildEntityTable } from "../table-builder";
@@ -25,7 +25,7 @@ const entity = createEntity({
25
25
  describe("decimal field — column + DDL", () => {
26
26
  test("maps to numeric(precision,scale) with required→NOT NULL", () => {
27
27
  const cols = new Map(
28
- buildEntityTableMeta("decimalProbe", entity).columns.map((c) => [c.name, c]),
28
+ deriveEntityTableMeta("decimalProbe", entity).columns.map((c) => [c.name, c]),
29
29
  );
30
30
  expect(cols.get("sum")?.pgType).toBe("numeric(14,2)");
31
31
  expect(cols.get("interest")?.pgType).toBe("numeric(6,4)");
@@ -35,7 +35,7 @@ describe("decimal field — column + DDL", () => {
35
35
  });
36
36
 
37
37
  test("renders real numeric(p,s) DDL", () => {
38
- const ddl = renderTableDdl(buildEntityTableMeta("decimalProbe", entity)).join("\n");
38
+ const ddl = renderTableDdl(deriveEntityTableMeta("decimalProbe", entity)).join("\n");
39
39
  expect(ddl).toContain('"interest" numeric(6,4) NOT NULL');
40
40
  expect(ddl).toContain('"rate" numeric(12,2)');
41
41
  });
@@ -1,4 +1,4 @@
1
- // #1210: buildEntityTableMeta() hardcoded source: "managed", so unmanaged
1
+ // #1210: deriveEntityTableMeta() hardcoded source: "managed", so unmanaged
2
2
  // direct-write stores (store_user_sessions, store_api_tokens, mail sync/seen
3
3
  // cursors) were misclassified as rebuildable event-sourced projections — a
4
4
  // destructive column change would DROP+rebuild-from-events tables that have
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
10
  import { createEntity, createTextField } from "../../engine";
11
- import { buildEntityTableMeta } from "../entity-table-meta";
11
+ import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
12
12
  import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
13
13
 
14
14
  const entity = createEntity({
@@ -19,14 +19,14 @@ const entity = createEntity({
19
19
  },
20
20
  });
21
21
 
22
- describe("buildEntityTableMeta — options.source (#1210)", () => {
22
+ describe("deriveEntityTableMeta — options.source (#1210)", () => {
23
23
  test("defaults to managed when omitted", () => {
24
- expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
24
+ expect(deriveEntityTableMeta("source-probe", entity).source).toBe("managed");
25
25
  });
26
26
 
27
27
  test("options.source: 'unmanaged' changes only source, columns + piiSubjectFields stay identical", () => {
28
- const managed = buildEntityTableMeta("source-probe", entity);
29
- const unmanaged = buildEntityTableMeta("source-probe", entity, { source: "unmanaged" });
28
+ const managed = deriveEntityTableMeta("source-probe", entity);
29
+ const unmanaged = deriveEntityTableMeta("source-probe", entity, { source: "unmanaged" });
30
30
 
31
31
  expect(managed.source).toBe("managed");
32
32
  expect(unmanaged.source).toBe("unmanaged");
@@ -37,9 +37,9 @@ describe("buildEntityTableMeta — options.source (#1210)", () => {
37
37
  });
38
38
 
39
39
  test("flipping an existing table's meta to unmanaged produces an empty migration diff", () => {
40
- const prevSnapshot = snapshotFromMetas([buildEntityTableMeta("source-probe", entity)]);
40
+ const prevSnapshot = snapshotFromMetas([deriveEntityTableMeta("source-probe", entity)]);
41
41
  const nextSnapshot = snapshotFromMetas([
42
- buildEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
42
+ deriveEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
43
43
  ]);
44
44
 
45
45
  const diff = diffSnapshots(prevSnapshot, nextSnapshot);
@@ -47,4 +47,39 @@ describe("buildEntityTableMeta — options.source (#1210)", () => {
47
47
  expect(diff.droppedTables).toEqual([]);
48
48
  expect(diff.changedTables).toEqual([]);
49
49
  });
50
+
51
+ test("deprecated buildEntityTableMeta alias still works", async () => {
52
+ const { buildEntityTableMeta } = await import("../entity-table-meta");
53
+ expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
54
+ });
55
+ });
56
+
57
+ describe("unmanaged builders reject read_ prefix (#1208)", () => {
58
+ test("deriveEntityTableMeta(..., { source: unmanaged }) with read_ table throws", () => {
59
+ const readEntity = createEntity({
60
+ table: "read_source_probe",
61
+ fields: { userId: createTextField({ required: true }) },
62
+ });
63
+ expect(() =>
64
+ deriveEntityTableMeta("source-probe", readEntity, { source: "unmanaged" }),
65
+ ).toThrow(/the "read_" prefix is reserved/);
66
+ });
67
+
68
+ test("default toTableName (read_*) + unmanaged throws", () => {
69
+ const noTable = createEntity({
70
+ fields: { userId: createTextField({ required: true }) },
71
+ });
72
+ expect(() => deriveEntityTableMeta("source-probe", noTable, { source: "unmanaged" })).toThrow(
73
+ /the "read_" prefix is reserved/,
74
+ );
75
+ });
76
+
77
+ test("defineUnmanagedTable with read_ tableName throws", () => {
78
+ expect(() =>
79
+ defineUnmanagedTable({
80
+ tableName: "read_oops",
81
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
82
+ }),
83
+ ).toThrow(/the "read_" prefix is reserved/);
84
+ });
50
85
  });
@@ -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,79 @@ 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("block comment leaves a space so adjacent tokens do not fuse", () => {
38
+ expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
39
+ });
40
+
41
+ test("a block-comment opener inside a line comment does not swallow the next statement", () => {
42
+ const sql = `
43
+ -- note: see /* details below
44
+ CREATE TABLE "a" ("id" uuid);
45
+ /* real block comment */
46
+ CREATE TABLE "b" ("id" uuid);
47
+ `;
48
+ expect(splitSqlStatements(sql)).toEqual([
49
+ 'CREATE TABLE "a" ("id" uuid);',
50
+ 'CREATE TABLE "b" ("id" uuid);',
51
+ ]);
52
+ });
53
+
54
+ test("does not split on a semicolon inside a single-quoted string literal", () => {
55
+ const sql = `INSERT INTO "a" ("v") VALUES ('a;b');`;
56
+ expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a;b');`]);
57
+ });
58
+
59
+ test("does not treat a double-dash inside a string literal as a comment", () => {
60
+ const sql = `INSERT INTO "a" ("v") VALUES ('a--b');`;
61
+ expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a--b');`]);
62
+ });
63
+
64
+ test("handles an escaped quote inside a single-quoted string literal ('')", () => {
65
+ const sql = `INSERT INTO "a" ("v") VALUES ('a'';b');`;
66
+ expect(splitSqlStatements(sql)).toEqual([`INSERT INTO "a" ("v") VALUES ('a'';b');`]);
67
+ });
68
+
69
+ test("does not split on a semicolon inside a double-quoted identifier", () => {
70
+ const sql = `CREATE TABLE "weird;name" ("id" uuid);`;
71
+ expect(splitSqlStatements(sql)).toEqual([`CREATE TABLE "weird;name" ("id" uuid);`]);
72
+ });
73
+
74
+ test("throws fail-loud on an unterminated block comment instead of silently dropping statements", () => {
75
+ const sql = `/* oops\nCREATE TABLE "a" ("id" uuid);`;
76
+ expect(() => splitSqlStatements(sql)).toThrow(/unterminated blockComment/);
77
+ });
78
+
79
+ test("throws fail-loud on an unterminated single-quoted string", () => {
80
+ const sql = `INSERT INTO "a" ("v") VALUES ('oops;`;
81
+ expect(() => splitSqlStatements(sql)).toThrow(/unterminated singleQuote/);
82
+ });
83
+
84
+ test("throws fail-loud on an unterminated double-quoted identifier", () => {
85
+ const sql = `CREATE TABLE "weird;`;
86
+ expect(() => splitSqlStatements(sql)).toThrow(/unterminated doubleQuote/);
87
+ });
88
+
89
+ test("a trailing line comment without a newline terminates cleanly", () => {
90
+ expect(splitSqlStatements('CREATE TABLE "a" ("id" uuid);\n-- done')).toEqual([
91
+ 'CREATE TABLE "a" ("id" uuid);',
92
+ ]);
93
+ });
19
94
  });
@@ -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 {