@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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 (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. package/src/utils/serialization.ts +10 -3
@@ -1,7 +1,7 @@
1
1
  import { requestContext } from "../api/request-context";
2
2
  import type { DbConnection, DbRow, DbTx } from "../db/connection";
3
3
  import { selectRowForUpdateById } from "../db/queries/entity-read";
4
- import { selectMany, transaction } from "../db/query";
4
+ import { asEntityTableMeta, selectMany, transaction } from "../db/query";
5
5
  import { buildEntityTable, toSnakeCase } from "../db/table-builder";
6
6
  import { createTenantDb } from "../db/tenant-db";
7
7
  import { hasAccess } from "../engine/access";
@@ -717,12 +717,8 @@ export function createDispatcher(
717
717
  user,
718
718
  ip: reqCtx?.ip,
719
719
  });
720
- // skip: ip-bucketed handler called from a non-HTTP entry point (job, seed,
721
- // MSP-apply) no client IP to bucket on, nothing to enforce. Pass
722
- // through BEFORE requiring a resolver, so system/seed writes through
723
- // such a handler don't need a RateLimitResolver wired (the es-ops
724
- // seed dispatcher has none). L1/L2 middleware handle the HTTP-side
725
- // ip caps.
720
+ // skip: ip-bucket + no IP (non-HTTP entry point) pass through before
721
+ // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
726
722
  if (bucket.kind === "skip") return;
727
723
  if (!context.rateLimit) {
728
724
  throw new InternalError({
@@ -1162,9 +1158,7 @@ export function createDispatcher(
1162
1158
  // can false-pass; optimistic locking would catch it later, but with
1163
1159
  // a less specific error. Falls back to a plain SELECT if no tx is
1164
1160
  // active (tests without a DB connection).
1165
- const tableName = String(
1166
- (table as { [key: symbol]: unknown })[Symbol.for("kumiko:schema:Name")],
1167
- );
1161
+ const tableName = asEntityTableMeta(table)?.tableName ?? "";
1168
1162
  const rows = tx
1169
1163
  ? await selectRowForUpdateById(handlerContext.db, tableName, id)
1170
1164
  : await selectMany(handlerContext.db, table, { id });
@@ -11,11 +11,16 @@ import {
11
11
  rebuildMetaOrThrow,
12
12
  swapShadowIntoLive,
13
13
  } from "../db/queries/shadow-swap";
14
- import { selectMany } from "../db/query";
14
+ import { runInSavepoint, selectMany } from "../db/query";
15
15
  import type { Registry, TenantId } from "../engine/types";
16
16
  import { InternalError } from "../errors";
17
17
  import { eventsTable, type StoredEvent, upcastStoredEvent } from "../event-store";
18
18
  import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
19
+ import {
20
+ createRebuildDeadLetterTable,
21
+ recordRebuildDeadLetters,
22
+ type SkippedApply,
23
+ } from "../event-store/rebuild-dead-letter";
19
24
  import { upcastStoredEvents } from "../event-store/upcaster";
20
25
  import { emitProjectionRebuild } from "../observability/standard-metrics";
21
26
  import type { Meter } from "../observability/types/metric";
@@ -123,12 +128,21 @@ export async function rebuildMultiStreamProjection(
123
128
 
124
129
  const meta = rebuildMetaOrThrow(msp.table, mspName);
125
130
 
131
+ // Declared per-projection (MspErrorMode): the rebuild policy falls back to
132
+ // continuous when omitted — previously declared but never honored here.
133
+ const skipApplyErrors =
134
+ (msp.errorMode?.rebuild ?? msp.errorMode?.continuous)?.skipApplyErrors === true;
135
+
126
136
  const startedAt = Date.now();
127
137
  let eventsProcessed = 0;
128
138
  let lastProcessedEventId = 0n;
139
+ const skipped: SkippedApply[] = [];
129
140
 
130
141
  try {
131
142
  await ensureRebuildSchema(db);
143
+ // Outside the rebuild tx, like the schema: idempotent DDL colliding
144
+ // inside the tx would roll the whole replay back.
145
+ if (skipApplyErrors) await createRebuildDeadLetterTable(db);
132
146
  await db.begin(async (tx: DbTx) => {
133
147
  await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
134
148
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
@@ -178,13 +192,31 @@ export async function rebuildMultiStreamProjection(
178
192
  });
179
193
  const applyFn = msp.apply[row.type];
180
194
  if (!applyFn) continue;
181
- const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
182
- await applyFn(storedEvent, tx, rebuildCtx);
195
+ if (skipApplyErrors) {
196
+ // Driver-native savepoint: a throwing SQL statement would
197
+ // otherwise poison the rebuild tx (25P02) AND make the driver
198
+ // reject the whole begin() even after a caught error. Apply +
199
+ // ctx run on the savepoint-scoped handle.
200
+ try {
201
+ await runInSavepoint(tx, async (sp) => {
202
+ const rebuildCtx = createRebuildCtx(registry, sp as DbRunner, row.tenantId);
203
+ await applyFn(storedEvent, sp as DbRunner, rebuildCtx);
204
+ });
205
+ } catch (e) {
206
+ skipped.push({ event: storedEvent, error: e });
207
+ }
208
+ } else {
209
+ const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
210
+ await applyFn(storedEvent, tx, rebuildCtx);
211
+ }
183
212
  eventsProcessed++;
184
213
  lastProcessedEventId = row.id;
185
214
  }
186
215
  }
187
216
 
217
+ if (skipped.length > 0) {
218
+ await recordRebuildDeadLetters(tx, mspName, skipped);
219
+ }
188
220
  await updateConsumerRebuildCursor(
189
221
  tx,
190
222
  mspName,
@@ -210,6 +242,7 @@ export async function rebuildMultiStreamProjection(
210
242
  const result: RebuildResult = {
211
243
  projection: mspName,
212
244
  eventsProcessed,
245
+ eventsSkipped: skipped.length,
213
246
  lastProcessedEventId,
214
247
  durationMs: Date.now() - startedAt,
215
248
  };
@@ -13,7 +13,7 @@ import {
13
13
  rebuildMetaOrThrow,
14
14
  swapShadowIntoLive,
15
15
  } from "../db/queries/shadow-swap";
16
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
16
+ import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query";
17
17
  import type { Registry, TenantId } from "../engine/types";
18
18
  import {
19
19
  eventsTable,
@@ -22,6 +22,11 @@ import {
22
22
  upcastStoredEvent,
23
23
  } from "../event-store";
24
24
  import type { EventMetadata } from "../event-store/event-store";
25
+ import {
26
+ createRebuildDeadLetterTable,
27
+ recordRebuildDeadLetters,
28
+ type SkippedApply,
29
+ } from "../event-store/rebuild-dead-letter";
25
30
  import { emitProjectionRebuild } from "../observability/standard-metrics";
26
31
  import type { Meter } from "../observability/types/metric";
27
32
  import { projectionStateTable } from "./projection-state";
@@ -119,7 +124,12 @@ function rowToStoredEvent(row: StoredEventRow): StoredEvent {
119
124
 
120
125
  export type RebuildResult = {
121
126
  readonly projection: string;
127
+ // Events consumed from the log — includes quarantined ones (the cursor
128
+ // advanced past them).
122
129
  readonly eventsProcessed: number;
130
+ // Poison events quarantined into kumiko_rebuild_dead_letters this run.
131
+ // Always 0 unless quarantine mode was active (#760).
132
+ readonly eventsSkipped: number;
123
133
  readonly lastProcessedEventId: bigint;
124
134
  readonly durationMs: number;
125
135
  };
@@ -145,12 +155,25 @@ type RebuildDeps = {
145
155
  // on the live table; if a long-running writer holds it past this, the rebuild
146
156
  // fails loud instead of hanging. Defaults to DEFAULT_FENCE_LOCK_TIMEOUT_MS.
147
157
  readonly fenceLockTimeoutMs?: number;
158
+ // Apply-error policy for THIS run. Default strict: the first throwing
159
+ // apply aborts the rebuild (tx rollback, status "failed") — a poison
160
+ // event makes the projection permanently un-rebuildable (#760).
161
+ // skipApplyErrors: true confines every apply to a savepoint; a throwing
162
+ // apply is rolled back, recorded into kumiko_rebuild_dead_letters and
163
+ // skipped, and the rebuild completes without its effect. Opt-in for
164
+ // operators unblocking a pinned rebuild — the projection then knowingly
165
+ // misses the quarantined events until they are repaired and replayed.
166
+ readonly errorPolicy?: { readonly skipApplyErrors?: boolean };
148
167
  // Test-only seam: fires once after the unlocked bulk drain and before the
149
168
  // cutover fence. Lets a concurrency test inject a committed write into the
150
169
  // replay window deterministically. The `__test_` prefix marks it test-only:
151
170
  // a slow callback here delays the cutover fence acquisition, widening the
152
171
  // window in which concurrent writers can still commit below the cursor.
153
172
  readonly __test_onBeforeFence?: () => void | Promise<void>;
173
+ // Test-only seam: fires each time the shadow table is (re)built. The #443
174
+ // recompute is idempotent on final values, so only a call-count seam like
175
+ // this can prove it didn't fire on a clean, non-concurrent rebuild.
176
+ readonly __test_onBuildShadowTable?: () => void;
154
177
  };
155
178
 
156
179
  export async function rebuildProjection(
@@ -170,7 +193,7 @@ export async function rebuildProjection(
170
193
  const meta = rebuildMetaOrThrow(projection.table, projectionName);
171
194
 
172
195
  const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
173
- const sourcesList = [...sources];
196
+ const sourcesList = [...sources, ...(projection.extraSources ?? [])];
174
197
  const subscribedList = Object.keys(projection.apply);
175
198
  // Upcasters run at read time: older stored payloads get walked through the
176
199
  // registered r.eventMigration chain until their shape matches the current
@@ -181,6 +204,11 @@ export async function rebuildProjection(
181
204
  const startedAt = Date.now();
182
205
  let eventsProcessed = 0;
183
206
  let lastProcessedEventId = 0n;
207
+ const skipApplyErrors = deps.errorPolicy?.skipApplyErrors === true;
208
+ // Quarantined events, collected in memory and written once after the
209
+ // replay settles — the #443 full-re-replay path would otherwise duplicate
210
+ // the dead-letter rows.
211
+ let skipped: SkippedApply[] = [];
184
212
 
185
213
  // One chronological batch of events after lastProcessedEventId, applied into
186
214
  // the shadow. Returns the batch size so the caller can detect the tail
@@ -204,7 +232,21 @@ export async function rebuildProjection(
204
232
  // skip: apply-key validation ensures every subscribed type has a handler;
205
233
  // defensive check against runtime-mutated registry
206
234
  if (!applyFn) continue;
207
- await applyFn(storedEvent, tx);
235
+ if (skipApplyErrors) {
236
+ // Driver-native savepoint: a throwing SQL statement would otherwise
237
+ // poison the rebuild tx (25P02) AND make the driver reject the whole
238
+ // begin() even after a caught error. The apply runs on the
239
+ // savepoint-scoped handle so its statements are confined.
240
+ try {
241
+ await runInSavepoint(tx, async (sp) => {
242
+ await applyFn(storedEvent, sp as DbTx, projection.table);
243
+ });
244
+ } catch (e) {
245
+ skipped.push({ event: storedEvent, error: e });
246
+ }
247
+ } else {
248
+ await applyFn(storedEvent, tx, projection.table);
249
+ }
208
250
  eventsProcessed++;
209
251
  lastProcessedEventId = row.id;
210
252
  }
@@ -213,9 +255,13 @@ export async function rebuildProjection(
213
255
 
214
256
  try {
215
257
  await ensureRebuildSchema(db);
258
+ // Outside the rebuild tx, like the schema: idempotent DDL colliding
259
+ // inside the tx would roll the whole replay back.
260
+ if (skipApplyErrors) await createRebuildDeadLetterTable(db);
216
261
  await db.begin(async (tx: DbTx) => {
217
262
  await markProjectionRebuilding(tx, projectionName);
218
263
  await buildShadowTable(tx, meta);
264
+ deps.__test_onBuildShadowTable?.();
219
265
 
220
266
  // A projection that subscribes to nothing has no events to replay and no
221
267
  // live writer touching its table — skip straight to swapping the empty
@@ -253,14 +299,19 @@ export async function rebuildProjection(
253
299
  const expectedEvents = await countSubscribedEvents(tx, sourcesList, subscribedList);
254
300
  if (expectedEvents > BigInt(eventsProcessed)) {
255
301
  await buildShadowTable(tx, meta);
302
+ deps.__test_onBuildShadowTable?.();
256
303
  lastProcessedEventId = 0n;
257
304
  eventsProcessed = 0;
305
+ skipped = [];
258
306
  while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
259
307
  // re-replay the full log into the fresh shadow
260
308
  }
261
309
  }
262
310
  }
263
311
 
312
+ if (skipped.length > 0) {
313
+ await recordRebuildDeadLetters(tx, projectionName, skipped);
314
+ }
264
315
  await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
265
316
  await swapShadowIntoLive(tx, meta.tableName);
266
317
  });
@@ -286,6 +337,7 @@ export async function rebuildProjection(
286
337
  const result: RebuildResult = {
287
338
  projection: projectionName,
288
339
  eventsProcessed,
340
+ eventsSkipped: skipped.length,
289
341
  lastProcessedEventId,
290
342
  durationMs: Date.now() - startedAt,
291
343
  };
@@ -51,6 +51,6 @@ export async function runProjectionsForEvent(
51
51
  const applyFn = proj.apply[event.type];
52
52
  // skip: this projection doesn't care about this event type
53
53
  if (!applyFn) continue;
54
- await applyFn(event, tx);
54
+ await applyFn(event, tx, proj.table);
55
55
  }
56
56
  }
package/src/schema-cli.ts CHANGED
@@ -44,17 +44,28 @@ export type SchemaCliOut = {
44
44
 
45
45
  const SNAPSHOT_FILENAME = ".snapshot.json";
46
46
 
47
- async function loadEntityMetasFromApp(
48
- schemaFile: string,
49
- ): Promise<Parameters<typeof renderTablesDdl>[0]> {
47
+ type SchemaMod = {
48
+ readonly entityMetas: Parameters<typeof renderTablesDdl>[0];
49
+ readonly features?: unknown;
50
+ };
51
+
52
+ // Single load-path for kumiko/schema.ts (512/3) — `generate` and `validate`
53
+ // both need ENTITY_METAS (and validate additionally wants FEATURES); this is
54
+ // the one place that enforces "ENTITY_METAS must be an array" so the two
55
+ // commands can't drift on the check or its wording. Throws — callers that
56
+ // want a graceful exit code (validate) catch it themselves.
57
+ async function loadSchemaModFromApp(schemaFile: string): Promise<SchemaMod> {
50
58
  // bun imports TS directly — no spawn needed.
51
- const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown };
59
+ const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown; FEATURES?: unknown };
52
60
  if (!Array.isArray(mod.ENTITY_METAS)) {
53
61
  throw new Error(
54
62
  `Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
55
63
  );
56
64
  }
57
- return mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
65
+ return {
66
+ entityMetas: mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0],
67
+ features: mod.FEATURES,
68
+ };
58
69
  }
59
70
 
60
71
  function nextSequenceNumber(migrationsDir: string): number {
@@ -132,7 +143,7 @@ export async function runSchemaCli(
132
143
  out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
133
144
  return 1;
134
145
  }
135
- const metas = await loadEntityMetasFromApp(schemaFile);
146
+ const { entityMetas: metas } = await loadSchemaModFromApp(schemaFile);
136
147
  const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
137
148
  const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
138
149
  const result = generateMigration({
@@ -191,20 +202,18 @@ export async function runSchemaCli(
191
202
  out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
192
203
  return 1;
193
204
  }
194
- const mod = (await import(schemaFile)) as {
195
- ENTITY_METAS?: unknown;
196
- FEATURES?: unknown;
197
- };
198
- if (!Array.isArray(mod.ENTITY_METAS)) {
199
- out.err(
200
- ` Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
201
- );
205
+ let schemaMod: SchemaMod;
206
+ try {
207
+ schemaMod = await loadSchemaModFromApp(schemaFile);
208
+ } catch (e) {
209
+ out.err(` ${e instanceof Error ? e.message : String(e)}`);
202
210
  return 1;
203
211
  }
212
+ const mod = { FEATURES: schemaMod.features };
204
213
  let ok = true;
205
214
 
206
215
  // 1. Schema drift — compute the diff, never write.
207
- const metas = mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
216
+ const metas = schemaMod.entityMetas;
208
217
  const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
209
218
  const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
210
219
  const drift = generateMigration({
@@ -32,6 +32,18 @@ export type _R6TypeAssertions = [
32
32
  // the backstop. This is what keeps generic-over-response handlers compiling.
33
33
  Expect<Equal<ContainsSecret<unknown>, false>>,
34
34
  Expect<Equal<ContainsSecret<never>, false>>,
35
+ // 556/3: ContainsSecret<A | B> distributes over the union (naked type
36
+ // param) to `ContainsSecret<A> | ContainsSecret<B>` = `boolean`, not the
37
+ // literal `true` — pinned here so a future change to this predicate can't
38
+ // silently regress it. The actual leak-guard in define-handler.ts compares
39
+ // via `true extends ContainsSecret<TData>` (556/1) instead of `ContainsSecret<
40
+ // TData> extends true`, which is fail-closed for `boolean` either way.
41
+ Expect<Equal<ContainsSecret<{ ok: true } | { s: Secret<string> }>, boolean>>,
42
+ // 556/2: Map/Set are explicit SafeLeaf members now — pins that the runtime
43
+ // guard's "walk Map/Set entries separately" intent is mirrored, not an
44
+ // accidental compile-time blind spot.
45
+ Expect<Equal<ContainsSecret<Map<string, Secret<string>>>, false>>,
46
+ Expect<Equal<ContainsSecret<Set<Secret<string>>>, false>>,
35
47
  ];
36
48
 
37
49
  const schema = z.object({ q: z.string() });
@@ -57,6 +69,28 @@ function genericResponseHandler<K extends string>(kind: K) {
57
69
  }
58
70
  void genericResponseHandler;
59
71
 
72
+ // 556/1: ContainsSecret<A | B> distributes over the union (naked type param
73
+ // in the condition) to `ContainsSecret<A> | ContainsSecret<B>` = `boolean`,
74
+ // not the literal `true` — a check written as `ContainsSecret<TData> extends
75
+ // true` silently passes a union with a leak hidden in ONE branch. Both
76
+ // assertions below must hold: the guard rejects the leaking union AND still
77
+ // accepts the clean one, proving the fix (membership form on the phantom
78
+ // param) is fail-closed for unions, not just for a bare Secret<>.
79
+ declare const leakingUnion: { ok: true } | { apiKey: Secret<string> };
80
+ // @ts-expect-error — R6: a Secret<> hidden in one union branch must still leak-guard
81
+ defineQueryHandler({
82
+ name: "t:query:union-leak",
83
+ schema,
84
+ handler: async () => leakingUnion,
85
+ });
86
+
87
+ declare const cleanUnion: { ok: true } | { id: string };
88
+ defineQueryHandler({
89
+ name: "t:query:union-clean",
90
+ schema,
91
+ handler: async () => cleanUnion,
92
+ });
93
+
60
94
  describe("R6 ContainsSecret", () => {
61
95
  test("a clean query handler still builds — the guard param is invisible", () => {
62
96
  const def = defineQueryHandler({
@@ -56,6 +56,11 @@ type Primitive = string | number | boolean | bigint | symbol | null | undefined;
56
56
 
57
57
  // Opaque built-in leaves a response legitimately carries; never recurse into
58
58
  // them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
59
+ // Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
60
+ // already fell through to `false` via the object-mapped-type branch — listed
61
+ // explicitly here so the compile-time treatment matches leak-guard.ts's
62
+ // runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
63
+ // instead of looking like an oversight.
59
64
  type SafeLeaf =
60
65
  | Date
61
66
  | RegExp
@@ -66,7 +71,9 @@ type SafeLeaf =
66
71
  | Temporal.PlainTime
67
72
  | Temporal.PlainYearMonth
68
73
  | Temporal.PlainMonthDay
69
- | Temporal.Duration;
74
+ | Temporal.Duration
75
+ | Map<unknown, unknown>
76
+ | Set<unknown>;
70
77
 
71
78
  export type ContainsSecret<T> = [T] extends [never]
72
79
  ? false
@@ -32,7 +32,10 @@ function resolveClearableTable(table: ClearableTable): unknown {
32
32
  /** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
33
33
  export async function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
34
34
  for (const table of tables) {
35
- await deleteMany(db, resolveClearableTable(table), {});
35
+ // Test-teardown truncate: resolveClearableTable returns `unknown` (string
36
+ // name or table object). The brand-strip cast is the sanctioned bypass —
37
+ // clearing a managed projection between tests is not a production write.
38
+ await deleteMany(db, resolveClearableTable(table) as EntityTableMeta, {});
36
39
  }
37
40
  }
38
41
 
@@ -31,6 +31,7 @@ export {
31
31
  createRecordingProvider,
32
32
  type RecordingProvider,
33
33
  } from "./observability-recorder";
34
+ export { deleteRows, seedRow, seedRows, updateRows } from "./seed";
34
35
  export {
35
36
  sharedItemEntity,
36
37
  sharedItemTable,
@@ -0,0 +1,50 @@
1
+ // Test-only ES-bypass: seed / mutate a managed projection directly, skipping
2
+ // the event log. Production code CANNOT do this — the write helpers reject a
3
+ // branded EntityTable at compile time (#742). Tests seed read-model state to
4
+ // arrange a scenario without driving the full event path; a projection rebuild
5
+ // is not part of the test lifecycle, so eventless writes are safe here. The
6
+ // `as EntityTableMeta` strips the executor-only brand at this single sanctioned
7
+ // seam — the signatures mirror the query helpers so a call-site migration is a
8
+ // plain identifier rename.
9
+
10
+ import {
11
+ type AnyDb,
12
+ deleteMany,
13
+ insertMany,
14
+ insertOne,
15
+ updateMany,
16
+ type WhereObject,
17
+ } from "../bun-db/query";
18
+ import type { EntityTableMeta } from "../db/entity-table-meta";
19
+ import type { EntityTable } from "../db/table-builder";
20
+
21
+ type SeedTable = EntityTable | EntityTableMeta;
22
+
23
+ export function seedRow<TRow = unknown>(
24
+ db: AnyDb,
25
+ table: SeedTable,
26
+ values: Record<string, unknown>,
27
+ ): Promise<TRow | undefined> {
28
+ return insertOne<TRow>(db, table as EntityTableMeta, values);
29
+ }
30
+
31
+ export function seedRows<TRow = unknown>(
32
+ db: AnyDb,
33
+ table: SeedTable,
34
+ rows: ReadonlyArray<Record<string, unknown>>,
35
+ ): Promise<readonly TRow[]> {
36
+ return insertMany<TRow>(db, table as EntityTableMeta, rows);
37
+ }
38
+
39
+ export function updateRows<TRow = unknown>(
40
+ db: AnyDb,
41
+ table: SeedTable,
42
+ set: Record<string, unknown>,
43
+ where: WhereObject,
44
+ ): Promise<readonly TRow[]> {
45
+ return updateMany<TRow>(db, table as EntityTableMeta, set, where);
46
+ }
47
+
48
+ export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<void> {
49
+ return deleteMany(db, table as EntityTableMeta, where);
50
+ }
@@ -9,6 +9,13 @@ describe("warnIfNonUtcServerTimeZone", () => {
9
9
  expect(messages).toHaveLength(0);
10
10
  });
11
11
 
12
+ test.each(["GMT", "Etc/UTC"])("warnt nicht bei UTC-äquivalenter Zone %s", (zone) => {
13
+ const messages: string[] = [];
14
+ const warned = warnIfNonUtcServerTimeZone(zone, (m) => messages.push(m));
15
+ expect(warned).toBe(false);
16
+ expect(messages).toHaveLength(0);
17
+ });
18
+
12
19
  test("warnt bei nicht-UTC Zone, nennt Zone + UTC-Hinweis", () => {
13
20
  const messages: string[] = [];
14
21
  const warned = warnIfNonUtcServerTimeZone("Europe/Berlin", (m) => messages.push(m));
@@ -18,7 +25,22 @@ describe("warnIfNonUtcServerTimeZone", () => {
18
25
  expect(messages[0]).toContain("UTC");
19
26
  });
20
27
 
21
- test("Default-Aufruf liest die Prozess-TZ ohne zu werfen", () => {
22
- expect(() => warnIfNonUtcServerTimeZone(undefined, () => {})).not.toThrow();
28
+ test("Default-Aufruf liest die ECHTE Prozess-TZ (ambient, nicht injiziert)", () => {
29
+ // Das ist der einzige Test der ambient-TZ liest statt sie zu injizieren —
30
+ // der Grund warum der tz-matrix-CI-Job (4 Legs: LA/Berlin/Tokyo/Apia)
31
+ // überhaupt einen Unterschied zwischen den Legs sehen kann. Ohne diese
32
+ // Assertion wäre "läuft in jeder Zone ohne zu werfen" in allen 4 Legs
33
+ // identisch grün — die beworbene Schutzwirkung ("ein `new Date(wallClock)`-
34
+ // Bug bricht HIER") würde nicht existieren.
35
+ const ambientTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
36
+ const messages: string[] = [];
37
+ const warned = warnIfNonUtcServerTimeZone(undefined, (m) => messages.push(m));
38
+ if (ambientTz === "UTC") {
39
+ expect(warned).toBe(false);
40
+ expect(messages).toHaveLength(0);
41
+ } else {
42
+ expect(warned).toBe(true);
43
+ expect(messages[0]).toContain(ambientTz);
44
+ }
23
45
  });
24
46
  });
@@ -55,11 +55,17 @@ describe("ctx.tz — GeoTzProvider seam", () => {
55
55
  test("fromAddress wirft wenn der Provider kein fromAddress hat (offline lat/lng)", async () => {
56
56
  const provider: GeoTzProvider = { fromCoordinates: () => "UTC" };
57
57
  const tz = createTzContext({ geoTz: provider });
58
- await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(/fromAddress/);
58
+ // Regression (680/2): this message must say the PROVIDER lacks
59
+ // fromAddress, not the misleading "no provider configured" message.
60
+ await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
61
+ /GeoTzProvider that implements fromAddress/,
62
+ );
59
63
  });
60
64
 
61
- test("fromAddress wirft ohne Provider", async () => {
65
+ test("fromAddress wirft ohne Provider — eigene Message, nicht die Provider-ohne-fromAddress-Message", async () => {
62
66
  const tz = createTzContext();
63
- await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(/GeoTzProvider/);
67
+ await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
68
+ /requires a GeoTzProvider — inject one/,
69
+ );
64
70
  });
65
71
  });
@@ -25,6 +25,15 @@ describe("isValidIanaTimeZone", () => {
25
25
  expect(isValidIanaTimeZone(value)).toBe(false);
26
26
  });
27
27
 
28
+ // Intl.supportedValuesOf("timeZone") listet nur kanonische Namen — gültige
29
+ // IANA-Aliase fehlen darin, obwohl Intl.DateTimeFormat/Temporal/ctx.tz.parse
30
+ // sie klaglos akzeptieren. Ein valider Alias darf hier nicht als "invalid"
31
+ // rejected werden (stiller Breaking-Change für Consumer, die Alias-Zonen
32
+ // speichern).
33
+ test.each(["US/Pacific", "GMT", "Etc/UTC"])("akzeptiert gültigen IANA-Alias %s", (zone) => {
34
+ expect(isValidIanaTimeZone(zone)).toBe(true);
35
+ });
36
+
28
37
  test("liefert über mehrere Aufrufe konsistent (lazy Set gecacht)", () => {
29
38
  expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
30
39
  expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
@@ -17,7 +17,11 @@ export function warnIfNonUtcServerTimeZone(
17
17
  // biome-ignore lint/suspicious/noConsole: boot-time warning, no logger wired this early
18
18
  warn: (message: string) => void = console.warn,
19
19
  ): boolean {
20
- if (resolvedTimeZone === "UTC") return false;
20
+ // GMT and Etc/UTC are UTC-equivalent (no offset, no DST) TZ=GMT is a
21
+ // legitimate way to pin a process to UTC and must not trip this warning.
22
+ if (resolvedTimeZone === "UTC" || resolvedTimeZone === "GMT" || resolvedTimeZone === "Etc/UTC") {
23
+ return false;
24
+ }
21
25
  warn(
22
26
  `[kumiko] Server time zone is "${resolvedTimeZone}" — the framework assumes UTC. ` +
23
27
  "Set TZ=UTC for the server process to avoid time-zone-dependent bugs.",
package/src/time/iana.ts CHANGED
@@ -1,17 +1,19 @@
1
- // IANA-Zonenname-Validierung gegen die Runtime-eigene Zonenliste
2
- // (Intl.supportedValuesOf). Das Set wird lazy einmal gebaut der Aufbau ist
3
- // der teure Teil (~400 kanonische Zonen), das Lookup danach O(1).
4
-
5
- let supportedZones: ReadonlySet<string> | undefined;
6
-
7
- function ianaZoneSet(): ReadonlySet<string> {
8
- if (supportedZones === undefined) {
9
- supportedZones = new Set(Intl.supportedValuesOf("timeZone"));
10
- }
11
- return supportedZones;
12
- }
13
-
14
- /** True wenn `value` ein gültiger kanonischer IANA-Zonenname ist (z.B. "Europe/Berlin", "UTC"). */
1
+ // IANA-Zonenname-Validierung. Intl.supportedValuesOf("timeZone") liefert nur
2
+ // KANONISCHE Namen gültige Aliase wie "US/Pacific", "GMT", "Etc/UTC" fehlen
3
+ // darin, obwohl Intl.DateTimeFormat, Temporal und ctx.tz.parse sie alle
4
+ // klaglos akzeptieren. Intl.DateTimeFormat selbst ist aber case-INSENSITIVE
5
+ // ("europe/berlin" resolved klaglos zu "Europe/Berlin") — ein reines
6
+ // try/catch würde also Tippfehler in der Groß-/Kleinschreibung durchlassen.
7
+ // Der exakte Vergleich mit resolvedOptions().timeZone schließt das: ein
8
+ // gültiger kanonischer Name ODER Alias resolved IMMER zu sich selbst
9
+ // (empirisch verifiziert für US/Pacific, GMT, Etc/UTC), ein falsch-gecasteter
10
+ // String resolved zur kanonischen Form und matcht damit nicht mehr exakt.
15
11
  export function isValidIanaTimeZone(value: string): boolean {
16
- return ianaZoneSet().has(value);
12
+ try {
13
+ return (
14
+ new Intl.DateTimeFormat(undefined, { timeZone: value }).resolvedOptions().timeZone === value
15
+ );
16
+ } catch {
17
+ return false;
18
+ }
17
19
  }
@@ -115,7 +115,12 @@ export function createTzContext(options: TzContextOptions = {}): TzContext {
115
115
  return geoTz.fromCoordinates(coords);
116
116
  },
117
117
  fromAddress: async (address) => {
118
- if (geoTz?.fromAddress === undefined) {
118
+ if (geoTz === undefined) {
119
+ throw new Error(
120
+ "ctx.tz.fromAddress requires a GeoTzProvider — inject one via the app context (e.g. buildServer({ context: { geoTzProvider } }) or runProdApp({ extraContext: { geoTzProvider } })) or install a provider package.",
121
+ );
122
+ }
123
+ if (geoTz.fromAddress === undefined) {
119
124
  throw new Error(
120
125
  "ctx.tz.fromAddress requires a GeoTzProvider that implements fromAddress (geocoding). Offline lat/lng providers only support fromCoordinates.",
121
126
  );
@@ -21,4 +21,10 @@ describe("parseRoles", () => {
21
21
  expect(parseRoles(42)).toEqual([]);
22
22
  expect(parseRoles({ roles: ["Admin"] })).toEqual([]);
23
23
  });
24
+
25
+ test("filters out non-string entries instead of returning a type-lying array (517/1)", () => {
26
+ expect(parseRoles([42, "Admin", true, null])).toEqual(["Admin"]);
27
+ expect(parseRoles("[42, true]")).toEqual([]);
28
+ expect(parseRoles('[42, "Admin"]')).toEqual(["Admin"]);
29
+ });
24
30
  });