@cosmicdrift/kumiko-framework 0.165.0 → 0.165.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) 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 +146 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/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 +178 -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 +26 -2
  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 +43 -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/__tests__/polyfill-reinstall.test.ts +17 -0
  142. package/src/time/geo-tz.ts +1 -1
  143. package/src/time/polyfill.ts +28 -39
  144. package/src/time/tz-context.ts +30 -24
  145. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  146. package/src/utils/safe-json.ts +3 -2
  147. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -1,3 +1,7 @@
1
+ // Value-only import, aliased to avoid shadowing the ambient global
2
+ // `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
3
+ // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
4
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
1
5
  import { requestContext } from "../api/request-context";
2
6
  import type { DbConnection, DbTx } from "../db/connection";
3
7
  import {
@@ -111,8 +115,18 @@ export async function acquireConsumerState(
111
115
  // a prior re-arm) gates the retry; maxRearmCount stops a poison event
112
116
  // from looping forever (re-arm → same event fails → dead → re-arm →
113
117
  // ...) — after the cap it stays dead until a human intervenes.
114
- const cooldownDeadline = Temporal.Now.instant().subtract({ milliseconds: rearmCooldownMs });
115
- const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
118
+ const cooldownDeadline = TemporalPolyfill.Now.instant().subtract({
119
+ milliseconds: rearmCooldownMs,
120
+ });
121
+ // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
122
+ // at runtime — state.updatedAt is DB-row-typed against the ambient
123
+ // global, two distinct nominal types across the two .d.ts sources (see
124
+ // event-store.ts).
125
+ const cooldownElapsed =
126
+ TemporalPolyfill.Instant.compare(
127
+ state.updatedAt as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
128
+ cooldownDeadline,
129
+ ) <= 0;
116
130
  if (cooldownElapsed && state.rearmCount < maxRearmCount) {
117
131
  const rearmed = await rearmDeadConsumer(tx, name, instanceId);
118
132
  const rearmedState =
@@ -120,10 +134,10 @@ export async function acquireConsumerState(
120
134
  (coerceRow(rearmed, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
121
135
  if (rearmedState) return { state: rearmedState, skip: null };
122
136
  }
123
- // ponytail: no log/metric fires when the rearm budget is exhausted here
124
- // (the exact "braucht manuellen Eingriff" moment) — queryable via
125
- // getConsumerState but silent otherwise. Add an emitDispatcherError-style
126
- // signal if ops needs a push instead of a dead+lag poll.
137
+ // Caller (event-dispatcher.ts's processConsumer) emits
138
+ // kumiko_event_consumer_rearm_exhausted_total once per (consumer,
139
+ // instance) transition into this branch it has the process-lifetime
140
+ // state to dedupe across poll passes that this pure function doesn't.
127
141
  return { state: null, skip: "dead" };
128
142
  }
129
143
  return { state, skip: null };
@@ -4,6 +4,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
4
4
  import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
5
5
  import {
6
6
  emitEventConsumerPassOutcome,
7
+ emitEventConsumerRearmExhausted,
7
8
  emitEventDispatcherListenConnected,
8
9
  getFallbackMeter,
9
10
  getFallbackTracer,
@@ -201,6 +202,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
201
202
  }
202
203
  const tracer: Tracer = options.tracer ?? getFallbackTracer();
203
204
  const meter: Meter = options.meter ?? getFallbackMeter();
205
+ // Tracks which (consumer, instanceId) pairs already fired
206
+ // kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
207
+ // across many poll passes emits the ops-signal once, not every pass
208
+ // (that would be log/metric spam for a state that hasn't changed).
209
+ // Process-lifetime only — restarting the dispatcher re-arms reporting,
210
+ // which is fine: a fresh process re-observing a still-dead consumer is
211
+ // exactly the "still needs a human" signal ops wants.
212
+ const reportedDeadConsumers = new Set<string>();
204
213
 
205
214
  let running = false;
206
215
  // Separate from `running` on purpose: pre-registration of consumer state
@@ -304,8 +313,21 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
304
313
  // disabled/dead. Nothing to deliver this pass.
305
314
  if (acquired.skip !== null) {
306
315
  span.setAttribute("consumer.skip_reason", acquired.skip);
316
+ if (acquired.skip === "dead") {
317
+ const reportKey = `${consumer.name}:${instanceId}`;
318
+ if (!reportedDeadConsumers.has(reportKey)) {
319
+ reportedDeadConsumers.add(reportKey);
320
+ emitEventConsumerRearmExhausted(meter, { consumer: consumer.name, instanceId });
321
+ }
322
+ }
323
+ // skip: skip reason already recorded on the span above (and, for
324
+ // "dead", already emitted as a metric) — nothing left to deliver.
307
325
  return;
308
326
  }
327
+ // Acquired normally (including via a successful auto-rearm) — clear
328
+ // any prior dead-report so a future exhaustion re-emits instead of
329
+ // staying permanently suppressed by this process's Set.
330
+ reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
309
331
 
310
332
  const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
311
333
  // skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
@@ -57,6 +57,8 @@ export {
57
57
  } from "./projection-state";
58
58
  export { runProjectionsForEvent } from "./projections-runner";
59
59
  export {
60
+ ACCESS_INVALIDATION_CONSUMER_NAME,
61
+ createAccessInvalidationEventConsumer,
60
62
  createSearchEventConsumer,
61
63
  createSseBroadcastEventConsumer,
62
64
  SEARCH_CONSUMER_NAME,
@@ -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(