@cosmicdrift/kumiko-framework 0.307.0 → 0.308.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 (161) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  3. package/src/__tests__/error-contract.integration.test.ts +1 -1
  4. package/src/__tests__/field-access.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
  9. package/src/__tests__/full-stack.integration.test.ts +1 -1
  10. package/src/__tests__/transition-guard.integration.test.ts +1 -1
  11. package/src/api/__tests__/api.test.ts +1 -1
  12. package/src/api/__tests__/batch.integration.test.ts +1 -1
  13. package/src/api/__tests__/body-limit.test.ts +1 -1
  14. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  15. package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
  16. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
  17. package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
  18. package/src/api/__tests__/nested-write.integration.test.ts +1 -1
  19. package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
  20. package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
  21. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  22. package/src/api/__tests__/server-boot-guards.test.ts +1 -1
  23. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  24. package/src/api/__tests__/sse-broker.test.ts +107 -13
  25. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
  26. package/src/api/auth-routes.ts +1 -1
  27. package/src/api/redis-sse-broker.ts +51 -11
  28. package/src/api/server.ts +11 -0
  29. package/src/api/sse-broker.ts +61 -22
  30. package/src/changes.json +37 -0
  31. package/src/compliance/override-schema.ts +1 -1
  32. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  33. package/src/crypto/kms-adapter.ts +1 -1
  34. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  35. package/src/db/queries/event-consumer.ts +98 -6
  36. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  37. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  38. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  39. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  47. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  48. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  49. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  50. package/src/engine/__tests__/engine.test.ts +1 -1
  51. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  52. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  53. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  54. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  55. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  56. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  57. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  58. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  59. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  62. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  63. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  64. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  65. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  66. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  67. package/src/engine/__tests__/registry.test.ts +1 -1
  68. package/src/engine/__tests__/screen.test.ts +1 -1
  69. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  70. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  71. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  72. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  75. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  76. package/src/engine/boot-validator/screens.ts +83 -0
  77. package/src/engine/define-handler.ts +2 -1
  78. package/src/engine/entity-handlers.ts +2 -1
  79. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  80. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  81. package/src/engine/feature-builder-state.ts +2 -1
  82. package/src/engine/feature-config-events-jobs.ts +2 -1
  83. package/src/engine/feature-entity-handlers.ts +2 -1
  84. package/src/engine/schema-builder.ts +1 -1
  85. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  86. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  87. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  88. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  89. package/src/env/__tests__/dry-run.test.ts +1 -1
  90. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  91. package/src/env/_zod-introspect.ts +1 -1
  92. package/src/env/dry-run.ts +1 -1
  93. package/src/env/index.ts +1 -1
  94. package/src/errors/__tests__/classes.test.ts +1 -1
  95. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  96. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  97. package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
  98. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  99. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  100. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  101. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  102. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
  103. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  104. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  106. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  107. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  108. package/src/observability/metrics-wiring.ts +1 -1
  109. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  110. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  111. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  112. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  113. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  114. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  115. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  120. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  121. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  122. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  123. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  127. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  128. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  129. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  130. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  131. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  132. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  133. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
  134. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  138. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  141. package/src/pipeline/dispatch-stream.ts +6 -1
  142. package/src/pipeline/event-dispatcher-delivery.ts +132 -37
  143. package/src/pipeline/event-dispatcher.ts +96 -9
  144. package/src/pipeline/index.ts +1 -0
  145. package/src/pipeline/msp-rebuild.ts +39 -1
  146. package/src/pipeline/pending-gap-ranges.ts +48 -0
  147. package/src/pipeline/system-hooks.ts +223 -68
  148. package/src/pipeline/write-origin.ts +1 -1
  149. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  150. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  151. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  152. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  153. package/src/search/derive-search-adapter-config.ts +27 -0
  154. package/src/search/index.ts +1 -0
  155. package/src/search/meilisearch-adapter.ts +96 -14
  156. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  157. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  158. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  159. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  160. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  161. package/src/testing/e2e-generator.ts +1 -1
@@ -12,6 +12,26 @@ export type SseEvent = {
12
12
  data: Record<string, unknown>;
13
13
  };
14
14
 
15
+ // What a publish invalidates. "user" is the broadest (and the historical
16
+ // default — every scope narrower than this is an opt-in from a caller that
17
+ // knows exactly which credential(s) it revoked).
18
+ export type AccessInvalidationScope =
19
+ | { readonly kind: "user" }
20
+ | { readonly kind: "all-except-session"; readonly keptSessionId: string }
21
+ | { readonly kind: "sessions"; readonly sessionIds: readonly string[] }
22
+ | { readonly kind: "pat-tokens"; readonly tokenIds: readonly string[] };
23
+
24
+ // What a stream's own listener is authenticated as — used to decide whether
25
+ // a narrow scope (sessions/pat-tokens) applies to it. A listener with
26
+ // neither field (should not normally happen — dispatch-stream.ts always
27
+ // passes at least one) is treated as unidentifiable and closed by every
28
+ // narrow scope, same fail-closed stance as a PAT stream facing a
29
+ // sessions-scope.
30
+ export type AccessInvalidationCredential = {
31
+ readonly sid?: string;
32
+ readonly patTokenId?: string;
33
+ };
34
+
15
35
  export type SseBroker = {
16
36
  addClient(channel: string, send: (event: SseEvent) => void, close: () => void): string;
17
37
  removeClient(channel: string, clientId: string): void;
@@ -27,22 +47,37 @@ export type SseBroker = {
27
47
  subscribeAccessInvalidation(
28
48
  userId: string,
29
49
  onInvalidate: () => void,
30
- ownSid?: string,
50
+ credential?: AccessInvalidationCredential,
31
51
  ): () => void;
32
- // `keptSessionId` spares exactly one stream: the caller's own session on a
33
- // "revoke all others" write. It is a keep-list, not a list of revoked
34
- // sessions, so a stream of a session already revoked without an event
35
- // (plain logout) still closes. Every other reason stays userwide.
36
- publishAccessInvalidation(userId: string, keptSessionId?: string): void;
52
+ // `scope` narrows which credential(s) close; omitted = userwide (every
53
+ // listener), the historical default and still what unrelated
54
+ // reasons (role change, membership removal) use.
55
+ publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void;
37
56
  };
38
57
 
39
- // Fail-closed: without a kept sid, or for a listener without its own sid
40
- // (PAT/bearer), nothing is spared.
41
- function isSparedByKeptSessionId(
42
- ownSid: string | undefined,
43
- keptSessionId: string | undefined,
58
+ // Fail-closed: a narrow scope only spares/limits a listener it can positively
59
+ // match against its own credential. A listener with neither sid nor
60
+ // patTokenId of its own can't be matched by "sessions"/"pat-tokens", so it
61
+ // closes — same reasoning "all-except-session" already used for a sidless
62
+ // (PAT/bearer) listener.
63
+ export function shouldInvalidateListener(
64
+ credential: AccessInvalidationCredential,
65
+ scope: AccessInvalidationScope,
44
66
  ): boolean {
45
- return keptSessionId !== undefined && ownSid === keptSessionId;
67
+ switch (scope.kind) {
68
+ case "user":
69
+ return true;
70
+ case "all-except-session":
71
+ return credential.sid !== scope.keptSessionId;
72
+ case "sessions":
73
+ return credential.sid !== undefined
74
+ ? scope.sessionIds.includes(credential.sid)
75
+ : credential.patTokenId === undefined;
76
+ case "pat-tokens":
77
+ return credential.patTokenId !== undefined
78
+ ? scope.tokenIds.includes(credential.patTokenId)
79
+ : credential.sid === undefined;
80
+ }
46
81
  }
47
82
 
48
83
  export function createSseBroker(): SseBroker {
@@ -51,11 +86,14 @@ export function createSseBroker(): SseBroker {
51
86
  // makes pushToChannel/publishAccessInvalidation reach every replica's
52
87
  // clients — this reference implementation stays single-process only.
53
88
  const channels = new Map<string, Map<string, SseClient>>();
54
- // Keyed by callback reference, value is the subscriber's own sid. Every
55
- // subscriber must pass a distinct closure (dispatch-stream.ts does, one per
56
- // stream). Two subscribes with the SAME reference for the same user
57
- // collapse into one listener, and the first unsubscribe kills both.
58
- const accessInvalidationListeners = new Map<string, Map<() => void, string | undefined>>();
89
+ // Keyed by callback reference, value is the subscriber's own credential.
90
+ // Every subscriber must pass a distinct closure (dispatch-stream.ts does,
91
+ // one per stream). Two subscribes with the SAME reference for the same
92
+ // user collapse into one listener, and the first unsubscribe kills both.
93
+ const accessInvalidationListeners = new Map<
94
+ string,
95
+ Map<() => void, AccessInvalidationCredential>
96
+ >();
59
97
 
60
98
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
61
99
  let clients = channels.get(channel);
@@ -103,14 +141,14 @@ export function createSseBroker(): SseBroker {
103
141
  return total;
104
142
  },
105
143
 
106
- subscribeAccessInvalidation(userId, onInvalidate, ownSid) {
144
+ subscribeAccessInvalidation(userId, onInvalidate, credential) {
107
145
  const channel = userAccessChannel(userId);
108
146
  let listeners = accessInvalidationListeners.get(channel);
109
147
  if (!listeners) {
110
148
  listeners = new Map();
111
149
  accessInvalidationListeners.set(channel, listeners);
112
150
  }
113
- listeners.set(onInvalidate, ownSid);
151
+ listeners.set(onInvalidate, credential ?? {});
114
152
  return () => {
115
153
  const current = accessInvalidationListeners.get(channel);
116
154
  // skip: already unsubscribed (e.g. stream ended after a publish already fired)
@@ -120,15 +158,16 @@ export function createSseBroker(): SseBroker {
120
158
  };
121
159
  },
122
160
 
123
- publishAccessInvalidation(userId, keptSessionId) {
161
+ publishAccessInvalidation(userId, scope) {
124
162
  const channel = userAccessChannel(userId);
125
163
  const listeners = accessInvalidationListeners.get(channel);
126
164
  // skip: no live stream is watching this user right now
127
165
  if (!listeners) return;
166
+ const resolvedScope: AccessInvalidationScope = scope ?? { kind: "user" };
128
167
  // Snapshot before iterating — a fired listener unsubscribes itself,
129
168
  // which would mutate `listeners` mid-iteration otherwise.
130
- for (const [onInvalidate, ownSid] of [...listeners]) {
131
- if (isSparedByKeptSessionId(ownSid, keptSessionId)) continue;
169
+ for (const [onInvalidate, credential] of [...listeners]) {
170
+ if (!shouldInvalidateListener(credential, resolvedScope)) continue;
132
171
  onInvalidate();
133
172
  }
134
173
  },
package/src/changes.json CHANGED
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "0.308.0",
4
+ "type": "fix",
5
+ "title": "startFrom \"now\" and MSP rebuild hand-off no longer lose out-of-order commits"
6
+ },
7
+ {
8
+ "version": "0.308.0",
9
+ "type": "fix",
10
+ "title": "Idle event-dispatcher passes no longer lock consumer rows or write WAL"
11
+ },
12
+ {
13
+ "version": "0.308.0",
14
+ "type": "breaking",
15
+ "title": "Boot validator rejects a projectionDetail screen/section/emptyState action with no resolvable icon (fw#3234)",
16
+ "migration": "Every screen.actions / section.actions / section.emptyState.action entry on a projectionDetail screen, and every section.actions entry on an entityEdit screen, must resolve an icon: either an explicit `icon` on the action, or an id the shared action-icon map (@cosmicdrift/kumiko-types resolveActionIcon) already derives from the full id or its last/first kebab segment. entityEdit's own screen-level `actions` are not checked (unchanged, out of scope for this rule). An action that fails this now fails boot instead of rendering without an icon; give it an explicit `icon` or rename it to an id the map covers."
17
+ },
18
+ {
19
+ "version": "0.308.0",
20
+ "type": "improvement",
21
+ "title": "EventConsumer.batchHandler — turn-level batching with per-event fallback, used by the search consumer"
22
+ },
23
+ {
24
+ "version": "0.308.0",
25
+ "type": "fix",
26
+ "title": "Meilisearch tenant indexes are configured automatically on first access"
27
+ },
28
+ {
29
+ "version": "0.308.0",
30
+ "type": "breaking",
31
+ "title": "Session and PAT revoke now close their own credential's open SSE stream, not just other reasons' userwide invalidations",
32
+ "migration": "An app-injected SseBroker implementation must update its subscribeAccessInvalidation/publishAccessInvalidation signatures: the third parameter of subscribeAccessInvalidation is now an AccessInvalidationCredential object (`{ sid?, patTokenId? }`) instead of a bare sid string, and the second parameter of publishAccessInvalidation is now an AccessInvalidationScope object (`{ kind: \"user\" } | { kind: \"all-except-session\"; keptSessionId } | { kind: \"sessions\"; sessionIds } | { kind: \"pat-tokens\"; tokenIds }`) instead of a bare keptSessionId string. Example: `subscribeAccessInvalidation(userId, cb, sid)` becomes `subscribeAccessInvalidation(userId, cb, { sid })`, and `publishAccessInvalidation(userId, keptSessionId)` becomes `publishAccessInvalidation(userId, { kind: \"all-except-session\", keptSessionId })`."
33
+ },
34
+ {
35
+ "version": "0.308.0",
36
+ "type": "fix",
37
+ "title": "zod namespace import lets client bundles tree-shake unused locales"
38
+ },
2
39
  {
3
40
  "version": "0.307.0",
4
41
  "type": "fix",
@@ -17,7 +17,7 @@
17
17
  // Schema — wer die overriden will, würde die Profile-Identitaet
18
18
  // zerstören.
19
19
 
20
- import { z } from "zod";
20
+ import * as z from "zod";
21
21
 
22
22
  // DurationSpec: { days } | { hours } — strict bedeutet beide Forms
23
23
  // muessen exakt 1 property haben (kein "{ days: 30, hours: 1 }").
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { afterEach, describe, expect, test } from "bun:test";
7
7
  import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import { createRegistry, defineFeature } from "../../engine";
10
10
  import type { TenantId } from "../../engine/types/identifiers";
11
11
  import {
@@ -3,7 +3,7 @@ import {
3
3
  type SubjectId,
4
4
  subjectIdToKey,
5
5
  } from "@cosmicdrift/kumiko-types/kms-adapter-types";
6
- import { z } from "zod";
6
+ import * as z from "zod";
7
7
  import type { TenantId } from "../engine/types/identifiers";
8
8
 
9
9
  export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { selectMany } from "../../bun-db";
11
11
  import {
12
12
  createEntity,
@@ -30,9 +30,60 @@ export async function lockEventConsumersShareMode(db: AnyDb): Promise<void> {
30
30
  await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
31
31
  }
32
32
 
33
+ // The MAX(id) horizon plus every currently-open id gap below it, read from a
34
+ // single snapshot (READ COMMITTED gives each statement its own). A gap can
35
+ // only later fill in if some transaction holding a lower id was still
36
+ // in-flight in that same snapshot — pg_snapshot_xip is empty otherwise, so
37
+ // the gap CTEs short-circuit and the window scan over kumiko_events never
38
+ // runs. Used both to seed startFrom "now" (#3231) and to seed an MSP
39
+ // rebuild's handoff to the live dispatcher (msp-rebuild.ts).
40
+ export async function selectEventIdHorizonWithMissingRanges(
41
+ db: AnyDb,
42
+ ): Promise<{ readonly horizon: bigint; readonly gaps: PendingGapEntry[] }> {
43
+ const rows = (await asRawClient(db).unsafe(
44
+ `WITH b AS (
45
+ SELECT COALESCE(MAX("id"), 0) AS horizon, MIN("id") AS min_id FROM "kumiko_events"
46
+ ),
47
+ r AS (
48
+ SELECT 1::bigint AS f, b.min_id - 1 AS t FROM b
49
+ WHERE b.min_id > 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
50
+ UNION ALL
51
+ SELECT w."id" + 1, w.next_id - 1
52
+ FROM (SELECT "id", lead("id") OVER (ORDER BY "id") AS next_id FROM "kumiko_events") w
53
+ WHERE w.next_id > w."id" + 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
54
+ )
55
+ SELECT b.horizon::text AS horizon,
56
+ pg_snapshot_xmax(pg_current_snapshot())::text AS xmax,
57
+ r.f::text AS gap_from,
58
+ r.t::text AS gap_to
59
+ FROM b LEFT JOIN r ON true
60
+ ORDER BY r.f`,
61
+ )) as ReadonlyArray<{
62
+ horizon: string;
63
+ xmax: string;
64
+ gap_from: string | null;
65
+ gap_to: string | null;
66
+ }>;
67
+ const first = rows[0];
68
+ if (first === undefined)
69
+ throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
70
+ const gaps: PendingGapEntry[] = [];
71
+ for (const row of rows) {
72
+ if (row.gap_from === null || row.gap_to === null) continue;
73
+ gaps.push({ from: row.gap_from, to: row.gap_to, xmax: row.xmax });
74
+ }
75
+ return { horizon: BigInt(first.horizon), gaps };
76
+ }
77
+
33
78
  // startFrom "now" seeds the FIRST-registration cursor at the current
34
79
  // MAX(events.id) instead of the column default 0 — mounting a consumer into
35
- // an existing app then skips the historical log instead of replaying it.
80
+ // an existing app then skips the historical log instead of replaying it. The
81
+ // horizon and pending_gaps are read from one snapshot (selectEventIdHorizon
82
+ // WithMissingRanges): a transaction holding a lower id can still be
83
+ // in-flight at boot, and without seeding those gaps its event would commit
84
+ // after the cursor already sits above it, so `id > cursor` would never
85
+ // deliver it (#3231). The cheap existence check skips the scan entirely for
86
+ // the (overwhelmingly common) case of an already-registered consumer.
36
87
  // ON CONFLICT DO NOTHING keeps both branches safe for an existing row: the
37
88
  // subquery only ever affects the row this statement inserts.
38
89
  export async function insertConsumerIfAbsent(
@@ -42,11 +93,18 @@ export async function insertConsumerIfAbsent(
42
93
  startFrom: "beginning" | "now" = "beginning",
43
94
  ): Promise<void> {
44
95
  if (startFrom === "now") {
96
+ const existing = (await asRawClient(db).unsafe(
97
+ `SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`,
98
+ [name, instanceId],
99
+ )) as ReadonlyArray<unknown>;
100
+ // skip: already registered — an existing cursor is never re-seeded.
101
+ if (existing.length > 0) return;
102
+ const { horizon, gaps } = await selectEventIdHorizonWithMissingRanges(db);
45
103
  await asRawClient(db).unsafe(
46
- `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id")
47
- VALUES ($1, $2, 'idle', COALESCE((SELECT MAX("id") FROM "kumiko_events"), 0))
104
+ `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id", "pending_gaps")
105
+ VALUES ($1, $2, 'idle', $3, $4::text::jsonb)
48
106
  ON CONFLICT ("name", "instance_id") DO NOTHING`,
49
- [name, instanceId],
107
+ [name, instanceId, horizon, JSON.stringify(gaps)],
50
108
  );
51
109
  } else {
52
110
  await asRawClient(db).unsafe(
@@ -83,6 +141,37 @@ export async function deleteOrphanedPerInstanceConsumerRows(
83
141
  );
84
142
  }
85
143
 
144
+ // Lock-free pre-check for doPass: which (name, instance_id) pairs are
145
+ // provably idle — no locking, no xid, no WAL. A pair only qualifies when its
146
+ // row exists, its status isn't dead (dead must still go through
147
+ // acquireConsumerState for auto-rearm), it has no pending_gaps, and no event
148
+ // exists past its cursor. Paired via unnest so the two positional arrays
149
+ // zip element-wise into rows instead of a cartesian product.
150
+ //
151
+ // `deadStatus` is passed in rather than imported from event-consumer-state.ts
152
+ // (which defines ConsumerStatuses) — that module already imports from this
153
+ // one, so importing back would be a require cycle (same pattern as
154
+ // deleteOrphanedPerInstanceConsumerRows above).
155
+ export async function selectProvablyIdleConsumerPairs(
156
+ db: AnyDb,
157
+ names: readonly string[],
158
+ instanceIds: readonly string[],
159
+ deadStatus: string,
160
+ ): Promise<ReadonlyArray<{ readonly name: string; readonly instanceId: string }>> {
161
+ const rows = (await asRawClient(db).unsafe(
162
+ `SELECT c."name" AS name, c."instance_id" AS instance_id
163
+ FROM "kumiko_event_consumers" c
164
+ WHERE ("name", "instance_id") = ANY (SELECT * FROM unnest($1::text[], $2::text[]))
165
+ AND "status" != $3
166
+ AND "pending_gaps" = '[]'::jsonb
167
+ AND NOT EXISTS (
168
+ SELECT 1 FROM "kumiko_events" e WHERE e."id" > c."last_processed_event_id"
169
+ )`,
170
+ [names, instanceIds, deadStatus],
171
+ )) as ReadonlyArray<{ name: string; instance_id: string }>;
172
+ return rows.map((r) => ({ name: r.name, instanceId: r.instance_id }));
173
+ }
174
+
86
175
  export async function selectConsumerForUpdateSkipLocked(
87
176
  db: AnyDb,
88
177
  name: string,
@@ -249,6 +338,7 @@ export async function updateConsumerRebuildCursor(
249
338
  name: string,
250
339
  instanceId: string,
251
340
  lastProcessedEventId: bigint,
341
+ pendingGaps: readonly PendingGapEntry[],
252
342
  ): Promise<void> {
253
343
  await asRawClient(db).unsafe(
254
344
  `UPDATE "kumiko_event_consumers" SET
@@ -256,9 +346,11 @@ export async function updateConsumerRebuildCursor(
256
346
  "status" = 'idle',
257
347
  "attempts" = 0,
258
348
  "last_error" = NULL,
349
+ -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
350
+ "pending_gaps" = $2::text::jsonb,
259
351
  "updated_at" = now()
260
- WHERE "name" = $2 AND "instance_id" = $3`,
261
- [lastProcessedEventId, name, instanceId],
352
+ WHERE "name" = $3 AND "instance_id" = $4`,
353
+ [lastProcessedEventId, JSON.stringify(pendingGaps), name, instanceId],
262
354
  );
263
355
  }
264
356
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
11
11
  import { InternalError, NotFoundError, writeFailure } from "../../errors";
12
12
  import { createFilesFeature } from "../../files/feature";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { validateBoot } from "../boot-validator";
4
4
  import { defineFeature } from "../define-feature";
5
5
  import { createEntity, createTextField } from "../factories";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { requiredKeysFromScreen } from "../../i18n/required-surface-keys";
4
4
  import { validateBoot } from "../boot-validator";
5
5
  import { defineFeature } from "../define-feature";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
4
  import { validateBoot as validateBootRaw } from "../boot-validator";
5
5
  import { defineFeature } from "../define-feature";
@@ -15,7 +15,7 @@
15
15
  // allowPlaintext-Marker unterdrückt Heuristik-Warnings.
16
16
 
17
17
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
18
- import { z } from "zod";
18
+ import * as z from "zod";
19
19
  import { validateBoot } from "../boot-validator";
20
20
  import { defineFeature } from "../define-feature";
21
21
  import {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { validateBoot } from "../boot-validator";
4
4
  import { defineFeature } from "../define-feature";
5
5
  import { createEntity, createTextField } from "../factories";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
4
  import { validateBoot as validateBootRaw } from "../boot-validator";
5
5
  import { defineFeature } from "../define-feature";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
4
  import { validateBoot as validateBootRaw } from "../boot-validator";
5
5
  import { defineFeature } from "../define-feature";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
4
  import { validateBoot as validateBootRaw } from "../boot-validator";
5
5
  import { defineFeature } from "../define-feature";
@@ -14,7 +14,7 @@
14
14
  // + handler-access mit ROLES.TenantAdmin
15
15
 
16
16
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
17
- import { z } from "zod";
17
+ import * as z from "zod";
18
18
  import { ROLES } from "../../auth";
19
19
  import { validateBoot } from "../boot-validator";
20
20
  import { defineFeature } from "../define-feature";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import type { SchemaTable } from "../../db/dialect";
4
4
  import { table, text } from "../../db/dialect";
5
5
  import { rowMetaFieldNames } from "../../db/table-builder";
@@ -4673,6 +4673,40 @@ describe("boot-validator", () => {
4673
4673
  );
4674
4674
  });
4675
4675
 
4676
+ test("projectionDetail tabs-mode fields section with both groups and actions → throw", () => {
4677
+ const feature = defineFeature("shop", (r) => {
4678
+ r.screen({
4679
+ id: "order-detail",
4680
+ type: "projectionDetail",
4681
+ query: "shop:query:order-detail",
4682
+ layout: {
4683
+ mode: "tabs",
4684
+ sections: [
4685
+ {
4686
+ id: "billing",
4687
+ title: "Billing",
4688
+ fields: [],
4689
+ groups: [{ title: "Amounts", fields: ["name"] }],
4690
+ actions: [
4691
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "order-detail" },
4692
+ ],
4693
+ },
4694
+ {
4695
+ id: "positions",
4696
+ kind: "relatedList",
4697
+ title: "Positions",
4698
+ query: "shop:query:order-positions",
4699
+ columns: [{ field: "name" }],
4700
+ },
4701
+ ],
4702
+ },
4703
+ });
4704
+ });
4705
+ expect(() => validateBoot([feature])).toThrow(
4706
+ /section "billing" declares both groups and actions in tabs mode/,
4707
+ );
4708
+ });
4709
+
4676
4710
  test("projectionDetail relatedList defaultSort.field not a listed column → throw", () => {
4677
4711
  const feature = defineFeature("shop", (r) => {
4678
4712
  r.screen({
@@ -8,7 +8,7 @@
8
8
  // im Browser-Bundle.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
- import { z } from "zod";
11
+ import * as z from "zod";
12
12
  import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
13
13
  import { defineFeature } from "../define-feature";
14
14
  import { createRegistry } from "../registry";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { buildConfigFeatureSchema } from "../build-config-feature-schema";
4
4
  import { createTenantConfig } from "../config-helpers";
5
5
  import { defineFeature } from "../define-feature";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { defineFeature } from "../define-feature";
4
4
  import { createEntity, createTextField } from "../factories";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createTestUser } from "../../stack";
4
4
  import { rolesOf } from "../../testing/access-assertions";
5
5
  import { hasAccess } from "../access";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { emitEvent, typedPayload } from "../event-helpers";
4
4
  import type { EventDef } from "../types/handlers";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { defineFeature } from "../define-feature";
4
4
  import type { DeclarativeEventMigration, EventUpcastCtx } from "../types";
5
5
 
@@ -3,7 +3,7 @@
3
3
  // custom-fields/__tests__/custom-fields.integration.test.ts.
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { z } from "zod";
6
+ import * as z from "zod";
7
7
  import { defineFeature } from "../define-feature";
8
8
  import { createEntity, createTextField } from "../factories";
9
9
  import { createRegistry } from "../registry";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { AccessDeniedError } from "../../errors";
4
4
  import { createGatedIdentitySwitch, type QueryAsFn } from "../../pipeline/system-identity-switch";
5
5
  import {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEntity, createRegistry, defineFeature, HookPhases } from "../index";
4
4
  import type { AppContext, PostSaveHookFn, SaveContext } from "../types";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createEntity, createRegistry, defineFeature } from "../index";
4
4
  import type { PostSaveHookFn, PreDeleteHookFn, PreSaveHookFn, SaveContext } from "../types";
5
5
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { describe, expect, it } from "bun:test";
7
7
  import { randomUUID } from "node:crypto";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import { TestUsers } from "../../stack";
10
10
  import { defineWriteHandler } from "../define-handler";
11
11
  import { defineStep } from "../define-step";
@@ -44,7 +44,7 @@
44
44
  // surface against an empty ctx mock; this file is the real-stack gate.
45
45
 
46
46
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
47
- import { z } from "zod";
47
+ import * as z from "zod";
48
48
  import { table, text, timestamp, uuid } from "../../db/dialect";
49
49
  import { createEventStoreExecutor } from "../../db/event-store-executor";
50
50
  import { asRawClient, selectMany } from "../../db/query";
@@ -14,7 +14,7 @@
14
14
  // and we wouldn't notice until a prod incident lacks the breadcrumbs.
15
15
 
16
16
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
- import { z } from "zod";
17
+ import * as z from "zod";
18
18
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
19
19
  import { createRecordingProvider, type RecordingProvider } from "../../testing";
20
20
  import { defineFeature } from "../define-feature";
@@ -23,7 +23,7 @@
23
23
  // here with a longer-pipeline handler variant.
24
24
 
25
25
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
26
- import { z } from "zod";
26
+ import * as z from "zod";
27
27
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
28
28
  import { defineFeature } from "../define-feature";
29
29
  import { defineWriteHandler } from "../define-handler";
@@ -6,7 +6,7 @@
6
6
  // hygiene, error-propagation and build-time guards.
7
7
 
8
8
  import { describe, expect, it } from "bun:test";
9
- import { z } from "zod";
9
+ import * as z from "zod";
10
10
  import { TestUsers } from "../../stack";
11
11
  import { defineWriteHandler } from "../define-handler";
12
12
  import { stepsPipeline } from "../pipeline";