@cosmicdrift/kumiko-framework 0.165.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +267 -19
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  13. package/src/api/__tests__/sse-broker.test.ts +57 -0
  14. package/src/api/__tests__/sse-route.test.ts +4 -0
  15. package/src/api/auth-routes.ts +165 -33
  16. package/src/api/index.ts +1 -0
  17. package/src/api/jwt.ts +22 -1
  18. package/src/api/routes.ts +103 -35
  19. package/src/api/server.ts +17 -1
  20. package/src/api/sse-broker.ts +39 -0
  21. package/src/bun-db/index.ts +1 -0
  22. package/src/bun-db/query.ts +12 -3
  23. package/src/consumer-cli.ts +60 -13
  24. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  25. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  26. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  27. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  28. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  29. package/src/db/api.ts +2 -2
  30. package/src/db/bun-provider.ts +2 -2
  31. package/src/db/connection.ts +6 -3
  32. package/src/db/dialect.ts +1 -6
  33. package/src/db/entity-table-meta-types.ts +1 -1
  34. package/src/db/event-store-executor-context.ts +2 -3
  35. package/src/db/event-store-executor-read.ts +2 -3
  36. package/src/db/event-store-executor-write.ts +8 -0
  37. package/src/db/index.ts +8 -1
  38. package/src/db/located-timestamp.ts +4 -0
  39. package/src/db/migrate-runner.ts +107 -11
  40. package/src/db/pg-error.ts +8 -0
  41. package/src/db/postgres-provider.ts +2 -2
  42. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  43. package/src/db/queries/ddl.ts +45 -0
  44. package/src/db/queries/event-store.ts +97 -5
  45. package/src/db/queries/test-stack.ts +4 -30
  46. package/src/db/reference-data.ts +2 -3
  47. package/src/db/replay-migration-sql.ts +114 -12
  48. package/src/db/tenant-db.ts +2 -4
  49. package/src/engine/__tests__/engine.test.ts +30 -0
  50. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  51. package/src/engine/__tests__/store-table.test.ts +2 -2
  52. package/src/engine/boot-validator/nav.ts +5 -0
  53. package/src/engine/constants.ts +32 -6
  54. package/src/engine/create-app.ts +11 -0
  55. package/src/engine/effective-features.ts +12 -2
  56. package/src/engine/extensions/user-data.ts +12 -4
  57. package/src/engine/feature-ui-extensions.ts +2 -2
  58. package/src/engine/hook-helpers.ts +3 -1
  59. package/src/engine/index.ts +1 -1
  60. package/src/engine/ownership.ts +4 -3
  61. package/src/engine/registry-ingest.ts +14 -14
  62. package/src/engine/registry-state.ts +4 -1
  63. package/src/engine/schema-builder.ts +1 -0
  64. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  65. package/src/engine/steps/_duration-utils.ts +2 -0
  66. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  67. package/src/engine/types/config.ts +1 -1
  68. package/src/engine/types/define-handler.ts +1 -1
  69. package/src/engine/types/entity-handlers.ts +1 -1
  70. package/src/engine/types/event-type-map.ts +1 -1
  71. package/src/engine/types/feature.ts +1 -1
  72. package/src/engine/types/fields.ts +1 -1
  73. package/src/engine/types/handlers.ts +1 -1
  74. package/src/engine/types/hooks.ts +1 -1
  75. package/src/engine/types/http-route.ts +1 -1
  76. package/src/engine/types/nav.ts +1 -1
  77. package/src/engine/types/ownership.ts +1 -1
  78. package/src/engine/types/projection.ts +1 -1
  79. package/src/engine/types/relations.ts +1 -1
  80. package/src/engine/types/screen.ts +1 -1
  81. package/src/engine/types/step.ts +1 -1
  82. package/src/engine/types/target-ref.ts +1 -1
  83. package/src/engine/types/tree-node.ts +1 -1
  84. package/src/engine/types/workspace.ts +1 -1
  85. package/src/engine/validate-projection-allowlist.ts +5 -5
  86. package/src/errors/classes.ts +21 -0
  87. package/src/errors/index.ts +1 -0
  88. package/src/errors/write-error-info.ts +6 -2
  89. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  90. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  91. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  92. package/src/event-store/admin-api.ts +11 -4
  93. package/src/event-store/event-store.ts +19 -4
  94. package/src/event-store/types.ts +1 -1
  95. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  96. package/src/files/__tests__/local-provider.test.ts +31 -0
  97. package/src/files/__tests__/write-stream.test.ts +3 -3
  98. package/src/files/index.ts +1 -1
  99. package/src/files/local-provider.ts +6 -1
  100. package/src/files/types.ts +8 -1
  101. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  102. package/src/jobs/job-runner.ts +41 -11
  103. package/src/logging/types.ts +1 -1
  104. package/src/observability/index.ts +1 -0
  105. package/src/observability/standard-metrics.ts +35 -2
  106. package/src/observability/types/index.ts +1 -1
  107. package/src/observability/types/metric.ts +1 -1
  108. package/src/observability/types/provider.ts +1 -1
  109. package/src/observability/types/span.ts +1 -1
  110. package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
  111. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  112. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  113. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  114. package/src/pipeline/dispatch-shared.ts +59 -6
  115. package/src/pipeline/dispatch-stream.ts +32 -11
  116. package/src/pipeline/dispatcher.ts +7 -1
  117. package/src/pipeline/event-consumer-state.ts +16 -13
  118. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  119. package/src/pipeline/event-dispatcher.ts +22 -0
  120. package/src/pipeline/index.ts +2 -0
  121. package/src/pipeline/system-hooks.ts +87 -0
  122. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  123. package/src/rate-limit/resolver.ts +6 -2
  124. package/src/schema-cli.ts +24 -12
  125. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  126. package/src/search/reindex-entity.ts +31 -2
  127. package/src/search/types.ts +1 -1
  128. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  129. package/src/stack/db.ts +2 -1
  130. package/src/stack/push-entity-projection-tables.ts +2 -1
  131. package/src/stack/request-helper.ts +20 -1
  132. package/src/stack/table-helpers.ts +6 -4
  133. package/src/stack/test-stack.ts +18 -15
  134. package/src/testing/__tests__/late-bound.test.ts +7 -0
  135. package/src/testing/__tests__/wait-for.test.ts +6 -0
  136. package/src/testing/file-provider-contract.ts +26 -6
  137. package/src/testing/index.ts +1 -0
  138. package/src/testing/late-bound.ts +5 -3
  139. package/src/testing/wait-for.ts +3 -0
  140. package/src/testing/without-ambient-temporal.ts +14 -0
  141. package/src/time/geo-tz.ts +1 -1
  142. package/src/time/polyfill.ts +21 -38
  143. package/src/time/tz-context.ts +37 -31
  144. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  145. package/src/utils/safe-json.ts +13 -1
  146. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -298,3 +298,90 @@ export function createJobTriggerEventConsumer(
298
298
  },
299
299
  };
300
300
  }
301
+
302
+ // --- Access-Invalidation Consumer (async, via event-dispatcher) ---
303
+ //
304
+ // #1524 (Design), #1558 (channel + stream subscribe), #1559 (session-revoke
305
+ // emits an event). This consumer is the last leg: it watches for the two
306
+ // event types that can make an already-issued JWT stale mid-stream and
307
+ // pushes an invalidation through sseBroker.publishAccessInvalidation, which
308
+ // dispatch-stream.ts's subscribeAccessInvalidation listener turns into an
309
+ // AccessDeniedError thrown into the open stream.
310
+ //
311
+ // Event types are literal strings, not imports — this package (framework)
312
+ // cannot depend on bundled-features (sessions, tenant own these events),
313
+ // mirrors the es-ops-seed precedent (literal QNs, no subpath import).
314
+ //
315
+ // "sessions:event:session-revoked" — payload.userId direct (own aggregate,
316
+ // see bundled-features/sessions/session-revoked-event.ts). Fired for
317
+ // both self-service revoke and the privileged cross-tenant
318
+ // revoke-all-for-user (SYSTEM_TENANT_ID-anchored DSGVO Art.18 freeze).
319
+ // fetchPendingEvents has no tenant predicate, so the SYSTEM_TENANT_ID-
320
+ // anchored event reaches this consumer the same as any other — routing
321
+ // is purely on payload.userId, never on event.tenantId.
322
+ // "tenant-membership.updated" / "tenant-membership.deleted" — role change
323
+ // or member removal. userId isn't in payload.changes (update only
324
+ // carries the changed fields, e.g. { roles }) so it's read from
325
+ // payload.previous, the full pre-write entity snapshot written by
326
+ // createEventStoreExecutor. tenantMembershipEntity declares no
327
+ // encrypted/PII fields, so previous.userId is plaintext — no KMS
328
+ // decrypt step (see kumiko-framework#1560 PR discussion; the issue's
329
+ // original handoff assumed encryption that doesn't apply here).
330
+ //
331
+ // Over-invalidation (e.g. tenant-membership.updated fired by something
332
+ // other than a role change) is fail-safe: the stream closes, the client
333
+ // reconnects and re-authorizes. No opt-out/allowlist — #1524 chose
334
+ // global-by-default specifically so this can't be silently disabled per
335
+ // handler.
336
+ //
337
+ // Scope note: entityEventName also emits "tenant-membership.forgotten" (DSGVO
338
+ // erasure) and ".restored" — .restored re-grants access so ignoring it is
339
+ // correct, .forgotten is not currently produced by any user-data-rights
340
+ // pipeline for memberships but would be revocation-relevant if it ever is.
341
+ // Out of #1560's stated scope (session-revoke + role/membership change),
342
+ // tracked rather than handled speculatively here.
343
+ export const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
344
+
345
+ const SESSION_REVOKED_EVENT_TYPE = "sessions:event:session-revoked";
346
+ const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
347
+ const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
348
+
349
+ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): string | undefined {
350
+ const previous = payload["previous"];
351
+ if (typeof previous !== "object" || previous === null) return undefined;
352
+ const userId = (previous as Record<string, unknown>)["userId"];
353
+ return typeof userId === "string" && userId.length > 0 ? userId : undefined;
354
+ }
355
+
356
+ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
357
+ return {
358
+ name: ACCESS_INVALIDATION_CONSUMER_NAME,
359
+ // Per-instance, same reasoning as SSE broadcast: subscribeAccessInvalidation
360
+ // listeners live in this process's in-memory sseBroker only. A shared
361
+ // cursor would deliver the event to exactly one instance and leave
362
+ // every other instance's open streams for that user un-invalidated.
363
+ delivery: "per-instance",
364
+ handler: async (event) => {
365
+ if (event.type === SESSION_REVOKED_EVENT_TYPE) {
366
+ const userId = event.payload["userId"];
367
+ // skip: malformed session-revoked payload — fail open on this one
368
+ // event rather than dead-lettering the whole consumer (halt-on-
369
+ // poison would otherwise permanently stop access-invalidation for
370
+ // every user behind one bad row).
371
+ if (typeof userId !== "string" || userId.length === 0) return;
372
+ sseBroker.publishAccessInvalidation(userId);
373
+ }
374
+
375
+ if (
376
+ event.type === TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE ||
377
+ event.type === TENANT_MEMBERSHIP_DELETED_EVENT_TYPE
378
+ ) {
379
+ const userId = readUserIdFromPreviousSnapshot(event.payload);
380
+ // skip: previous snapshot missing/malformed userId — same fail-open
381
+ // reasoning as above.
382
+ if (userId === undefined) return;
383
+ sseBroker.publishAccessInvalidation(userId);
384
+ }
385
+ },
386
+ };
387
+ }
@@ -169,6 +169,24 @@ describe("createRateLimitResolver — peek", () => {
169
169
  });
170
170
  });
171
171
 
172
+ describe("createRateLimitResolver — kumiko-framework#1525: no ambient Temporal global", () => {
173
+ test("check() and peek() compute resetAt without relying on globalThis.Temporal", async () => {
174
+ const config = { limit: 5, windowSeconds: 60 };
175
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
176
+ delete (globalThis as { Temporal?: unknown }).Temporal;
177
+ try {
178
+ const checked = await resolver.check("no-ambient:user", config);
179
+ expect(checked.resetAt).toBeDefined();
180
+
181
+ const peeked = await resolver.peek("no-ambient:user", config);
182
+ expect(peeked.resetAt).toBeDefined();
183
+ } finally {
184
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
185
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
186
+ }
187
+ });
188
+ });
189
+
172
190
  describe("createRateLimitResolver — enforce", () => {
173
191
  test("enforce throws RateLimitError with the bucket details when blocked", async () => {
174
192
  const config = { limit: 1, windowSeconds: 60 };
@@ -4,6 +4,10 @@ import type {
4
4
  RateLimitResolver,
5
5
  } from "@cosmicdrift/kumiko-types/rate-limit-types";
6
6
  import type Redis from "ioredis";
7
+ // Value-only import, aliased to avoid shadowing the ambient global
8
+ // `Temporal` TYPE that RateLimitDecision.resetAt resolves against (see
9
+ // event-store.ts for the same #1438 dual-package-hazard pattern).
10
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
7
11
  import { RateLimitError } from "../errors";
8
12
  import { RedisKeys } from "../pipeline/redis-keys";
9
13
 
@@ -189,7 +193,7 @@ export function createRateLimitResolver(opts: RateLimitResolverOptions): RateLim
189
193
  );
190
194
 
191
195
  const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
192
- const resetAt = Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
196
+ const resetAt = TemporalPolyfill.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
193
197
 
194
198
  return {
195
199
  allowed: allowedFlag === 1,
@@ -229,7 +233,7 @@ export function createRateLimitResolver(opts: RateLimitResolverOptions): RateLim
229
233
  );
230
234
 
231
235
  const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
232
- const resetAt = Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
236
+ const resetAt = TemporalPolyfill.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
233
237
 
234
238
  return {
235
239
  // peek doesn't deduct, so a "would-be" allowed flag is meaningful:
package/src/schema-cli.ts CHANGED
@@ -275,20 +275,32 @@ export async function runSchemaCli(
275
275
  // diff the reconstructed schema against .snapshot.json.
276
276
  const committedSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
277
277
  if (existsSync(migrationsDir) && committedSnapshot !== null) {
278
- const replayed = replayMigrationsDir(migrationsDir);
279
- const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
280
- if (mismatches.length === 0) {
281
- out.log(" ✓ migrations: committed SQL matches .snapshot.json");
282
- } else {
283
- ok = false;
284
- out.err(
285
- " ✗ migration-content drift: committed *.sql files don't produce .snapshot.json.",
286
- );
287
- for (const m of mismatches) {
288
- out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
278
+ try {
279
+ const replayed = replayMigrationsDir(migrationsDir);
280
+ const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
281
+ if (mismatches.length === 0) {
282
+ out.log(" ✓ migrations: table/column names match .snapshot.json");
283
+ } else {
284
+ ok = false;
285
+ out.err(
286
+ " ✗ migration-content drift: committed *.sql files don't produce .snapshot.json.",
287
+ );
288
+ for (const m of mismatches) {
289
+ out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
290
+ }
291
+ out.err(
292
+ " Fix: a migration file's body doesn't match what it (or the snapshot) claims — hand-fix the file, or ship a corrective migration if it's already applied in prod.",
293
+ );
289
294
  }
295
+ } catch (e) {
296
+ // replayMigrationsDir fail-loud's on a table-DDL statement it can't
297
+ // parse — that must surface as a normal ✗ line like every other
298
+ // check here, not a raw stack trace that kills the process before
299
+ // any later check (or the Fix: help) ever runs (framework#1535).
300
+ ok = false;
301
+ out.err(` ✗ migration-content: ${e instanceof Error ? e.message : String(e)}`);
290
302
  out.err(
291
- " Fix: a migration file's body doesn't match what it (or the snapshot) claimshand-fix the file, or ship a corrective migration if it's already applied in prod.",
303
+ " Fix: a statement in a migration file isn't recognizable to the replay parserextend the parser's recognized patterns, or reword the migration to one it understands.",
292
304
  );
293
305
  }
294
306
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  import {
9
+ asRawClient,
9
10
  buildEntityTable,
10
11
  createEventStoreExecutor,
11
12
  createTenantDb,
@@ -111,11 +112,33 @@ describe("reindexEntity", () => {
111
112
  admin.tenantId,
112
113
  { dryRun: true },
113
114
  );
114
- expect(result.indexedRows).toBeGreaterThan(0);
115
+ expect(result.indexedRows).toBe(0);
116
+ expect(result.wouldIndexRows).toBeGreaterThan(0);
115
117
 
116
118
  const postResults = await stack.search.search(admin.tenantId, "dryrun", {
117
119
  filterType: "widget",
118
120
  });
119
121
  expect(postResults).toHaveLength(0);
120
122
  });
123
+
124
+ // kumiko-framework#1549: a searchable field with no matching read-table
125
+ // column (dropped/never-migrated) used to push one identical failures[]
126
+ // entry per scanned row instead of failing fast — fails loud on the first
127
+ // row's column set instead, which is invariant across the whole scan.
128
+ test("throws immediately when a searchable field has no matching column, without scanning every row", async () => {
129
+ const executor = seedExecutor();
130
+ await executor.create({ name: "Row A" }, admin, tenantDb());
131
+ await executor.create({ name: "Row B" }, admin, tenantDb());
132
+
133
+ await asRawClient(stack.db).unsafe(`ALTER TABLE "read_reindex_widgets" DROP COLUMN "name"`);
134
+ try {
135
+ await expect(
136
+ reindexEntity(stack.db, stack.registry, stack.search, "widget", admin.tenantId),
137
+ ).rejects.toThrow(/searchable field "name" is not mappable/);
138
+ } finally {
139
+ await asRawClient(stack.db).unsafe(
140
+ `ALTER TABLE "read_reindex_widgets" ADD COLUMN "name" text`,
141
+ );
142
+ }
143
+ });
121
144
  });
@@ -21,6 +21,9 @@ export type ReindexEntityFailure = {
21
21
  export type ReindexEntityResult = {
22
22
  readonly scannedRows: number;
23
23
  readonly indexedRows: number;
24
+ // Only nonzero in a dry run — docs that would have been indexed. In a
25
+ // real run indexedRows already counts these; wouldIndexRows stays 0.
26
+ readonly wouldIndexRows: number;
24
27
  readonly failures: readonly ReindexEntityFailure[];
25
28
  };
26
29
 
@@ -82,9 +85,23 @@ export async function reindexEntity(
82
85
  const deletedFilter =
83
86
  entity.softDelete === true ? `AND ${quoteIdent("is_deleted")} IS NOT TRUE` : "";
84
87
 
85
- const result = { scannedRows: 0, indexedRows: 0, failures: [] as ReindexEntityFailure[] };
88
+ const result = {
89
+ scannedRows: 0,
90
+ indexedRows: 0,
91
+ wouldIndexRows: 0,
92
+ failures: [] as ReindexEntityFailure[],
93
+ };
86
94
 
87
95
  let offset = 0;
96
+ // Whether a searchable field lacks a matching read-table column depends
97
+ // only on the table's column set (rowToState/Object.hasOwn), not on any
98
+ // particular row's data — every row from the same SELECT * has the same
99
+ // columns. Check once against the first row instead of every row, so a
100
+ // dropped/never-migrated column fails fast with one clear error instead
101
+ // of one identical failures[] entry per scanned row (1M rows → 1M
102
+ // entries, and the real schema problem is buried in "x failed").
103
+ let uncheckedSchema = true;
104
+
88
105
  for (;;) {
89
106
  // ponytail: LIMIT/OFFSET, not a keyset cursor — id can be uuid or
90
107
  // serial depending on the entity, and a uniform cursor comparison
@@ -101,6 +118,18 @@ export async function reindexEntity(
101
118
  );
102
119
  if (rows.length === 0) break;
103
120
 
121
+ if (uncheckedSchema) {
122
+ uncheckedSchema = false;
123
+ const sampleState = rowToState(rows[0] as Record<string, unknown>, fieldNames);
124
+ const unmappedField = searchableFields.find((fieldName) => !(fieldName in sampleState));
125
+ if (unmappedField !== undefined) {
126
+ throw new Error(
127
+ `reindexEntity: searchable field "${unmappedField}" is not mappable from read-table ` +
128
+ `column ${quoteIdent(tableName)} — likely a dropped/never-migrated column.`,
129
+ );
130
+ }
131
+ }
132
+
104
133
  const docs: Array<{ entityId: string; doc: SearchDocument }> = [];
105
134
  for (const row of rows) {
106
135
  result.scannedRows++;
@@ -137,7 +166,7 @@ export async function reindexEntity(
137
166
  }
138
167
  }
139
168
  } else if (options.dryRun) {
140
- result.indexedRows += docs.length;
169
+ result.wouldIndexRows += docs.length;
141
170
  }
142
171
 
143
172
  offset += rows.length;
@@ -1 +1 @@
1
- export * from "@cosmicdrift/kumiko-types/search-adapter";
1
+ export type * from "@cosmicdrift/kumiko-types/search-adapter";
@@ -99,7 +99,7 @@ describe("setupTestStack({ jobs }) wires ctx.jobRunner for manual dispatch", ()
99
99
  });
100
100
 
101
101
  describe("setupTestStack({ jobs }) job context matches the request-path context", () => {
102
- test("job handler's context carries tracer + searchAdapter + effectiveFeatures, not a reduced literal", async () => {
102
+ test("job handler's context carries tracer + searchAdapter, mirrors prod's effectiveFeatures-less job context", async () => {
103
103
  jobContextFields.length = 0;
104
104
  stack = await setupTestStack({
105
105
  features: [jobContextFeature],
@@ -112,10 +112,14 @@ describe("setupTestStack({ jobs }) job context matches the request-path context"
112
112
  await waitFor(() => {
113
113
  expect(jobContextFields.length).toBeGreaterThan(0);
114
114
  });
115
+ // #1255: effectiveFeatures deliberately stays OUT of the job/appContext
116
+ // (prod only carries it in dispatcherOptions.effectiveFeatures, consumed
117
+ // by the command-dispatcher) — putting it here would let a job read
118
+ // `ctx.effectiveFeatures` green in tests and break on deploy.
115
119
  expect(jobContextFields[0]).toEqual({
116
120
  hasTracer: true,
117
121
  hasSearchAdapter: true,
118
- hasEffectiveFeatures: true,
122
+ hasEffectiveFeatures: false,
119
123
  });
120
124
  });
121
125
  });
package/src/stack/db.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Provider-agnostic via createConnection (DB_PROVIDER env).
3
3
  // postgres-js = default. DB_PROVIDER=bun = Bun.SQL (experimentell).
4
4
 
5
+ import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
5
6
  import { createConnection } from "../db/api";
6
7
  import { createDatabase, databaseExists, dropDatabaseIfExists } from "../db/queries/test-stack";
7
8
  import { ensureTemporalPolyfill } from "../time/polyfill";
@@ -18,7 +19,7 @@ function requireEnv(name: string): string {
18
19
  }
19
20
 
20
21
  export type TestDb = {
21
- db: unknown;
22
+ db: DbConnection;
22
23
  client: unknown;
23
24
  dbName: string;
24
25
  cleanup: () => Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
1
2
  import { tableExists } from "../db/schema-inspection";
2
3
  import type { Registry } from "../engine/types";
3
4
  import { unsafePushTables } from "./table-helpers";
@@ -35,7 +36,7 @@ export async function pushEntityProjectionTables(
35
36
  if (seen.has(proj.table)) continue;
36
37
  seen.add(proj.table);
37
38
  const tableRec = proj.table as unknown as Record<symbol, unknown>;
38
- const physical = tableRec[Symbol.for("kumiko:schema:Name")] as string;
39
+ const physical = tableRec[KUMIKO_NAME_SYMBOL] as string;
39
40
  if (await tableExists(stack.db, `public.${physical}`)) {
40
41
  logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
41
42
  continue;
@@ -111,10 +111,29 @@ export function createRequestHelper(
111
111
  jwt: JwtHelper,
112
112
  options: RequestHelperOptions = {},
113
113
  ): RequestHelper {
114
+ // sid per (user.id, tenantId), not one mint per authHeader() call:
115
+ // sessionCreator opens a live session row, so an unmemoized call mints a
116
+ // fresh one on every request a test makes for the same user — tests
117
+ // asserting on session counts / massRevoker / revokeAllOthers behavior
118
+ // then see extra live sids that have nothing to do with what they're
119
+ // testing. Keyed on tenantId too — the same user.id can hold sessions in
120
+ // more than one tenant, and sessionCreator writes the row scoped to
121
+ // `user.tenantId` (session-callbacks.ts), so a user.id-only key would
122
+ // hand a cross-tenant test its wrong tenant's sid.
123
+ // Cache the Promise (not the settled sid) so two concurrent authHeader
124
+ // calls for the same key share one mint instead of racing on a miss.
125
+ const sidByUserKey = new Map<string, Promise<string>>();
126
+
114
127
  async function authHeader(user: SessionUser): Promise<Record<string, string>> {
115
128
  let forJwt = user;
116
129
  if (options.sessionCreator && !user.sid) {
117
- const sid = await options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
130
+ const key = `${user.id}:${user.tenantId}`;
131
+ let sidPromise = sidByUserKey.get(key);
132
+ if (sidPromise === undefined) {
133
+ sidPromise = options.sessionCreator(user, { ip: "test", userAgent: "request-helper" });
134
+ sidByUserKey.set(key, sidPromise);
135
+ }
136
+ const sid = await sidPromise;
118
137
  forJwt = { ...user, sid };
119
138
  }
120
139
  const token = await jwt.sign(forJwt);
@@ -1,3 +1,7 @@
1
+ import {
2
+ KUMIKO_META_SYMBOL,
3
+ KUMIKO_NAME_SYMBOL,
4
+ } from "@cosmicdrift/kumiko-types/schema-table-types";
1
5
  import type { DbConnection } from "../db/connection";
2
6
  import { pgTypeToSqlType } from "../db/dialect";
3
7
  import type { ColumnMeta, EntityTableMeta } from "../db/entity-table-meta";
@@ -5,15 +9,13 @@ import {
5
9
  alterTableAddColumn,
6
10
  createIndexIfNotExists,
7
11
  executeDdlStatement,
8
- truncateTablesRestartIdentity,
9
- } from "../db/queries/test-stack";
12
+ } from "../db/queries/ddl";
13
+ import { truncateTablesRestartIdentity } from "../db/queries/test-stack";
10
14
  import { renderTableDdl } from "../db/render-ddl";
11
15
  import { tableExists } from "../db/schema-inspection";
12
16
  import { buildEntityTable, toTableName } from "../db/table-builder";
13
17
  import type { EventDispatcher } from "../pipeline";
14
18
 
15
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
16
- const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
17
19
  function tableNameOf(table: unknown): string {
18
20
  if (typeof table !== "object" || table === null) {
19
21
  throw new Error("table-helpers: table is not a SchemaTable object");
@@ -2,7 +2,7 @@ import type { Hono } from "hono";
2
2
  import type { AuthRoutesConfig } from "../api/auth-routes";
3
3
  import type { JwtHelper } from "../api/jwt";
4
4
  import { buildServer } from "../api/server";
5
- import { createSseBroker } from "../api/sse-broker";
5
+ import { createSseBroker, type SseBroker } from "../api/sse-broker";
6
6
  import type { PgClient } from "../db/connection";
7
7
  import { extractTableInfo } from "../db/query";
8
8
  import { createRegistry } from "../engine/registry";
@@ -25,13 +25,17 @@ export type TestStack = {
25
25
  app: Hono;
26
26
  jwt: JwtHelper;
27
27
  registry: Registry;
28
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
29
- db: any;
28
+ db: import("../db").DbConnection;
30
29
  redis: TestRedis;
31
30
  search: SearchAdapter;
32
31
  events: EventCollector;
33
32
  http: RequestHelper;
34
33
  observability: ObservabilityProvider;
34
+ // In-memory broker backing the server's SSE routes + access-invalidation
35
+ // channel. Tests subscribe directly (e.g. sseBroker.subscribeAccessInvalidation)
36
+ // to assert a consumer pushed an invalidation without opening a real SSE
37
+ // connection.
38
+ sseBroker: SseBroker;
35
39
  // Command-dispatcher behind the HTTP routes — for direct system-writes
36
40
  // in tests and dev-server extraRoutes (provider-webhook wiring).
37
41
  dispatcher: Dispatcher;
@@ -66,8 +70,7 @@ export type TestStackOptions = {
66
70
  | Record<string, unknown>
67
71
  | ((deps: {
68
72
  registry: Registry;
69
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
70
- db: any;
73
+ db: import("../db").DbConnection;
71
74
  sseBroker: import("../api/sse-broker").SseBroker;
72
75
  redis: import("ioredis").default;
73
76
  }) => Record<string, unknown>);
@@ -119,8 +122,7 @@ export type TestStackOptions = {
119
122
  | import("../api/server").ServerOptions["anonymousAccess"]
120
123
  | ((deps: {
121
124
  registry: Registry;
122
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
123
- db: any;
125
+ db: import("../db").DbConnection;
124
126
  sseBroker: import("../api/sse-broker").SseBroker;
125
127
  redis: import("ioredis").default;
126
128
  }) => import("../api/server").ServerOptions["anonymousAccess"]);
@@ -130,8 +132,7 @@ export type TestStackOptions = {
130
132
  base: import("../api/server").ServerOptions["anonymousAccess"] | undefined,
131
133
  deps: {
132
134
  registry: Registry;
133
- // biome-ignore lint/suspicious/noExplicitAny: cross-provider connection
134
- db: any;
135
+ db: import("../db").DbConnection;
135
136
  },
136
137
  ) => Promise<import("../api/server").ServerOptions["anonymousAccess"] | undefined>;
137
138
  /** Opt-in JobRunner wired into ctx.jobRunner and merged into
@@ -268,11 +269,12 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
268
269
  // (kumiko-framework#1232: a reduced `{ db, registry }` literal let jobs
269
270
  // pass in tests while reaching for fields only prod's context has).
270
271
  //
271
- // effectiveFeatures is included per the issue's explicit ask, even though
272
- // prod's job context never gets it today (only dispatcherOptions.
273
- // effectiveFeatures, consumed by the command-dispatcher — see
274
- // buildJobRunnerWithHook in entrypoint/index.ts). Tracked as a real prod
275
- // gap in a follow-up issue rather than silently matched here.
272
+ // effectiveFeatures deliberately stays OUT of appContext prod never puts
273
+ // it there either (only dispatcherOptions.effectiveFeatures, consumed by
274
+ // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
275
+ // index.ts). Putting it here would reintroduce the exact test-vs-prod
276
+ // drift #1232 fixed: a job/handler reading `ctx.effectiveFeatures` would
277
+ // pass in tests and break in prod (kumiko-framework#1255).
276
278
  const appContext = {
277
279
  db: testDb.db,
278
280
  redis: testRedis.redis,
@@ -281,7 +283,6 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
281
283
  registry,
282
284
  ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
283
285
  ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
284
- ...(options.effectiveFeatures ? { effectiveFeatures: options.effectiveFeatures } : {}),
285
286
  ...(typeof options.extraContext === "function"
286
287
  ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
287
288
  : options.extraContext),
@@ -377,6 +378,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
377
378
  systemConsumers: {
378
379
  sse: enabledHooks.includes("sse"),
379
380
  search: enabledHooks.includes("search"),
381
+ accessInvalidation: enabledHooks.includes("sse"),
380
382
  },
381
383
  },
382
384
  // Default tests to no login rate-limiter so existing suites that loop
@@ -437,6 +439,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
437
439
  events,
438
440
  http,
439
441
  observability: server.observability,
442
+ sseBroker,
440
443
  dispatcher: server.dispatcher,
441
444
  ...(eventDispatcher ? { eventDispatcher } : {}),
442
445
  ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
@@ -22,4 +22,11 @@ describe("createLateBoundHolder", () => {
22
22
  holder.set("second");
23
23
  expect(holder.get()).toBe("second");
24
24
  });
25
+
26
+ test("set(undefined) still marks the holder ready", () => {
27
+ const holder = createLateBoundHolder<number | undefined>("opt");
28
+ holder.set(undefined);
29
+ expect(holder.isReady()).toBe(true);
30
+ expect(holder.get()).toBeUndefined();
31
+ });
25
32
  });
@@ -39,6 +39,12 @@ describe("waitFor", () => {
39
39
  expect(calls).toBe(2);
40
40
  });
41
41
 
42
+ test("throws a descriptive error for an empty delay schedule", async () => {
43
+ await expect(waitFor(() => {}, { delays: [] })).rejects.toThrow(
44
+ "waitFor: empty delay schedule",
45
+ );
46
+ });
47
+
42
48
  test("supports an async fn", async () => {
43
49
  let calls = 0;
44
50
  await waitFor(
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import type { FileStorageProvider } from "../files/types";
3
3
 
4
4
  const bytes = (s: string) => new TextEncoder().encode(s);
@@ -18,19 +18,39 @@ export function describeFileProviderContract(
18
18
  ): void {
19
19
  describe(`${name} — FileStorageProvider contract`, () => {
20
20
  let provider: FileStorageProvider;
21
+ // Self-cleaning: every key this contract creates gets deleted in
22
+ // afterEach, so a persistent backend (local `kumiko dev` against a
23
+ // long-lived Minio, as opposed to CI's ephemeral one) doesn't leak
24
+ // `contract/<uuid>` objects into the next developer's session. delete()
25
+ // on a key that was never actually written is covered by the
26
+ // "no-op on a missing key" contract test above, so tracking generated
27
+ // keys unconditionally (even ones a test never got around to writing)
28
+ // is safe.
29
+ let writtenKeys: string[];
30
+
31
+ function trackedKey(ext: string): string {
32
+ const key = `contract/${crypto.randomUUID()}.${ext}`;
33
+ writtenKeys.push(key);
34
+ return key;
35
+ }
21
36
 
22
37
  beforeEach(async () => {
23
38
  provider = await factory();
39
+ writtenKeys = [];
40
+ });
41
+
42
+ afterEach(async () => {
43
+ for (const key of writtenKeys) await provider.delete(key);
24
44
  });
25
45
 
26
46
  test("write + read roundtrip preserves bytes", async () => {
27
- const key = `contract/${crypto.randomUUID()}.bin`;
47
+ const key = trackedKey("bin");
28
48
  await provider.write(key, bytes("hello contract"));
29
49
  expect(decode(await provider.read(key))).toBe("hello contract");
30
50
  });
31
51
 
32
52
  test("write on an existing key overwrites it (last-write-wins)", async () => {
33
- const key = `contract/${crypto.randomUUID()}.bin`;
53
+ const key = trackedKey("bin");
34
54
  await provider.write(key, bytes("first"));
35
55
  await provider.write(key, bytes("second"));
36
56
  expect(decode(await provider.read(key))).toBe("second");
@@ -41,7 +61,7 @@ export function describeFileProviderContract(
41
61
  });
42
62
 
43
63
  test("exists reflects write + delete", async () => {
44
- const key = `contract/${crypto.randomUUID()}.txt`;
64
+ const key = trackedKey("txt");
45
65
  expect(await provider.exists(key)).toBe(false);
46
66
  await provider.write(key, bytes("x"));
47
67
  expect(await provider.exists(key)).toBe(true);
@@ -56,7 +76,7 @@ export function describeFileProviderContract(
56
76
  });
57
77
 
58
78
  test("writeStream + readStream roundtrip preserves bytes", async () => {
59
- const key = `contract/${crypto.randomUUID()}.stream`;
79
+ const key = trackedKey("stream");
60
80
  await provider.writeStream(key, fromChunks([bytes("foo"), bytes("bar")]));
61
81
  let out = "";
62
82
  for await (const chunk of provider.readStream(key)) out += decode(chunk);
@@ -74,7 +94,7 @@ export function describeFileProviderContract(
74
94
  // skip: getSignedUrl is optional on the contract — feature-detected
75
95
  if (!provider.getSignedUrl) return;
76
96
 
77
- const key = `contract/${crypto.randomUUID()}.txt`;
97
+ const key = trackedKey("txt");
78
98
  await provider.write(key, bytes("signed"));
79
99
  const url = await provider.getSignedUrl(key, 60);
80
100
  expect(typeof url).toBe("string");
@@ -46,3 +46,4 @@ export {
46
46
  } from "./shared-entities";
47
47
  export { sleep } from "./utils";
48
48
  export { waitFor } from "./wait-for";
49
+ export { withoutAmbientTemporal } from "./without-ambient-temporal";
@@ -22,18 +22,20 @@ export type LateBoundHolder<T> = {
22
22
 
23
23
  export function createLateBoundHolder<T>(label = "value"): LateBoundHolder<T> {
24
24
  let value: T | undefined;
25
+ let isSet = false;
25
26
  return {
26
27
  set(v) {
27
28
  value = v;
29
+ isSet = true;
28
30
  },
29
31
  get() {
30
- if (value === undefined) {
32
+ if (!isSet) {
31
33
  throw new Error(`late-bound ${label} accessed before set() was called`);
32
34
  }
33
- return value;
35
+ return value as T;
34
36
  },
35
37
  isReady() {
36
- return value !== undefined;
38
+ return isSet;
37
39
  },
38
40
  };
39
41
  }