@cosmicdrift/kumiko-framework 0.306.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 (178) 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 +133 -0
  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 +144 -1
  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 +57 -3
  28. package/src/api/request-context.ts +24 -0
  29. package/src/api/server.ts +11 -0
  30. package/src/api/sse-broker.ts +69 -12
  31. package/src/changes.json +63 -0
  32. package/src/compliance/override-schema.ts +1 -1
  33. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  34. package/src/crypto/kms-adapter.ts +1 -1
  35. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  36. package/src/db/queries/event-consumer.ts +155 -9
  37. package/src/db/queries/event-store.ts +69 -0
  38. package/src/db/tenant-db.ts +43 -5
  39. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  50. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  51. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  52. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  53. package/src/engine/__tests__/engine.test.ts +1 -1
  54. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  55. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  56. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  57. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  58. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  59. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  62. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  63. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  65. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  66. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  67. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  68. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  69. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  70. package/src/engine/__tests__/registry.test.ts +1 -1
  71. package/src/engine/__tests__/screen.test.ts +1 -1
  72. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  75. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  76. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  79. package/src/engine/boot-validator/screens.ts +83 -0
  80. package/src/engine/define-handler.ts +2 -1
  81. package/src/engine/entity-handlers.ts +2 -1
  82. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  83. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  84. package/src/engine/feature-builder-state.ts +2 -1
  85. package/src/engine/feature-config-events-jobs.ts +2 -1
  86. package/src/engine/feature-entity-handlers.ts +2 -1
  87. package/src/engine/schema-builder.ts +1 -1
  88. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  89. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  90. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  91. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  92. package/src/env/__tests__/dry-run.test.ts +1 -1
  93. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  94. package/src/env/_zod-introspect.ts +1 -1
  95. package/src/env/dry-run.ts +1 -1
  96. package/src/env/index.ts +1 -1
  97. package/src/errors/__tests__/classes.test.ts +1 -1
  98. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  99. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  100. package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
  101. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  102. package/src/event-store/admin-api.ts +5 -0
  103. package/src/event-store/event-store.ts +16 -7
  104. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  108. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  111. package/src/jobs/job-runner.ts +61 -6
  112. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  113. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  114. package/src/observability/metrics-wiring.ts +1 -1
  115. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  120. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  121. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  122. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  127. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  128. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  129. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  130. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  131. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  132. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  133. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  134. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  138. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  141. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  143. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
  145. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  149. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  150. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  151. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  152. package/src/pipeline/dispatch-batch.ts +49 -19
  153. package/src/pipeline/dispatch-stream.ts +12 -3
  154. package/src/pipeline/dispatcher-utils.ts +21 -2
  155. package/src/pipeline/dispatcher.ts +71 -6
  156. package/src/pipeline/event-consumer-state.ts +26 -0
  157. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  158. package/src/pipeline/event-dispatcher-delivery.ts +212 -65
  159. package/src/pipeline/event-dispatcher.ts +253 -49
  160. package/src/pipeline/index.ts +1 -0
  161. package/src/pipeline/msp-rebuild.ts +39 -1
  162. package/src/pipeline/pending-gap-ranges.ts +120 -0
  163. package/src/pipeline/system-hooks.ts +226 -64
  164. package/src/pipeline/write-origin.ts +31 -10
  165. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  166. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  167. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  168. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  169. package/src/search/derive-search-adapter-config.ts +27 -0
  170. package/src/search/index.ts +1 -0
  171. package/src/search/meilisearch-adapter.ts +96 -14
  172. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  173. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  174. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  175. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  176. package/src/stack/test-stack.ts +1 -1
  177. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  178. package/src/testing/e2e-generator.ts +1 -1
package/src/api/server.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  type GlobalIpRateLimitOptions,
51
51
  globalIpRateLimit,
52
52
  } from "../rate-limit";
53
+ import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config";
53
54
  import type { SearchAdapter } from "../search/types";
54
55
  import { assertUnreachable, generateId } from "../utils";
55
56
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
@@ -488,6 +489,16 @@ export function buildServer(options: ServerOptions): KumikoServer {
488
489
  // @cast-boundary engine-bridge — searchAdapter is an optional context-extension
489
490
  const searchAdapter = (contextWithObservability as { searchAdapter?: SearchAdapter })
490
491
  .searchAdapter;
492
+ // Adapter is built before the registry exists (app boot order) — hand it
493
+ // the registry-derived default config here so it can lazily configure
494
+ // each tenant index on first access instead of requiring per-tenant app
495
+ // wiring.
496
+ const derivedSearchConfig = searchAdapter
497
+ ? deriveSearchAdapterConfig(options.registry)
498
+ : undefined;
499
+ if (searchAdapter && derivedSearchConfig) {
500
+ searchAdapter.setDefaultConfig?.(derivedSearchConfig);
501
+ }
491
502
 
492
503
  const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
493
504
  const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
@@ -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;
@@ -24,21 +44,56 @@ export type SseBroker = {
24
44
  // access-teardown security control (#1561) into a no-op — a revoked
25
45
  // session keeps receiving live SSE data with no error or log. A no-op
26
46
  // stub is one line for a broker that genuinely doesn't need it.
27
- subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
28
- publishAccessInvalidation(userId: string): void;
47
+ subscribeAccessInvalidation(
48
+ userId: string,
49
+ onInvalidate: () => void,
50
+ credential?: AccessInvalidationCredential,
51
+ ): () => 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;
29
56
  };
30
57
 
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,
66
+ ): boolean {
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
+ }
81
+ }
82
+
31
83
  export function createSseBroker(): SseBroker {
32
84
  // Purely local: no cross-replica fanout. buildServer wraps this in
33
85
  // createRedisSseBroker (fw#2625) whenever REDIS_URL is set, which is what
34
86
  // makes pushToChannel/publishAccessInvalidation reach every replica's
35
87
  // clients — this reference implementation stays single-process only.
36
88
  const channels = new Map<string, Map<string, SseClient>>();
37
- // Set, not Map<listenerId, fn> — dedup key is callback reference. Every
38
- // subscriber must pass a distinct closure (dispatch-stream.ts does, one
39
- // per stream). Two subscribes with the SAME reference for the same user
40
- // collapse into one listener, and the first unsubscribe kills both.
41
- const accessInvalidationListeners = new Map<string, Set<() => void>>();
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
+ >();
42
97
 
43
98
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
44
99
  let clients = channels.get(channel);
@@ -86,14 +141,14 @@ export function createSseBroker(): SseBroker {
86
141
  return total;
87
142
  },
88
143
 
89
- subscribeAccessInvalidation(userId, onInvalidate) {
144
+ subscribeAccessInvalidation(userId, onInvalidate, credential) {
90
145
  const channel = userAccessChannel(userId);
91
146
  let listeners = accessInvalidationListeners.get(channel);
92
147
  if (!listeners) {
93
- listeners = new Set();
148
+ listeners = new Map();
94
149
  accessInvalidationListeners.set(channel, listeners);
95
150
  }
96
- listeners.add(onInvalidate);
151
+ listeners.set(onInvalidate, credential ?? {});
97
152
  return () => {
98
153
  const current = accessInvalidationListeners.get(channel);
99
154
  // skip: already unsubscribed (e.g. stream ended after a publish already fired)
@@ -103,14 +158,16 @@ export function createSseBroker(): SseBroker {
103
158
  };
104
159
  },
105
160
 
106
- publishAccessInvalidation(userId) {
161
+ publishAccessInvalidation(userId, scope) {
107
162
  const channel = userAccessChannel(userId);
108
163
  const listeners = accessInvalidationListeners.get(channel);
109
164
  // skip: no live stream is watching this user right now
110
165
  if (!listeners) return;
166
+ const resolvedScope: AccessInvalidationScope = scope ?? { kind: "user" };
111
167
  // Snapshot before iterating — a fired listener unsubscribes itself,
112
168
  // which would mutate `listeners` mid-iteration otherwise.
113
- for (const onInvalidate of [...listeners]) {
169
+ for (const [onInvalidate, credential] of [...listeners]) {
170
+ if (!shouldInvalidateListener(credential, resolvedScope)) continue;
114
171
  onInvalidate();
115
172
  }
116
173
  },
package/src/changes.json CHANGED
@@ -1,4 +1,67 @@
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
+ },
39
+ {
40
+ "version": "0.307.0",
41
+ "type": "fix",
42
+ "title": "Event dispatcher no longer skips events that commit out of id order"
43
+ },
44
+ {
45
+ "version": "0.307.0",
46
+ "type": "fix",
47
+ "title": "A slow event consumer no longer delays every other consumer"
48
+ },
49
+ {
50
+ "version": "0.307.0",
51
+ "type": "breaking",
52
+ "title": "The public-intake gate now also covers jobs, event-triggered jobs, anonymous query roots and TenantDbs built from ctx.db.unsafeRaw (fw#3185)",
53
+ "migration": "An anonymous handler (roles include \"anonymous\") without personalData: \"public-intake\" now fails when personal data (pii / userOwned / recordOwned) is written through any of these paths: a job it enqueues (write or query root), a job triggered by its handler event or by an r.defineEvent it appends, a job chained from such a job, a job ctx.write/writeAs into another handler, or a TenantDb built with createTenantDb on a ctx.db.unsafeRaw / ctx.systemDb.unsafeRaw runner (including savepoints on it). The error is AccessDeniedError with details.reason \"public_intake_required\"; for jobs details.job names the job and the job run fails. Declare access: { roles: [..., \"anonymous\"], personalData: \"public-intake\" } on the root write handler if the anonymous intake is intended, otherwise stop writing the field from that path. A query root cannot declare public-intake: move the enqueue from an anonymous query handler into a write handler that declares it. Raw SQL through unsafeRaw stays escapeHatch plus audit. Jobs without a stamped origin (cron, boot, jobs dispatched outside a request, jobs queued before this release) run as before; a job whose _writeOrigin is present but invalid fails before its handler runs."
54
+ },
55
+ {
56
+ "version": "0.307.0",
57
+ "type": "fix",
58
+ "title": "Revoking all other sessions no longer closes the caller's own live stream"
59
+ },
60
+ {
61
+ "version": "0.307.0",
62
+ "type": "fix",
63
+ "title": "Version conflicts from custom writes return 409 instead of 500"
64
+ },
2
65
  {
3
66
  "version": "0.306.0",
4
67
  "type": "breaking",
@@ -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,
@@ -1,14 +1,89 @@
1
+ import type { PendingGapEntry } from "../../pipeline/event-consumer-state";
1
2
  import type { AnyDb } from "../query";
2
3
  import { asRawClient } from "../query";
3
4
 
5
+ // Per-turn snapshot bounds for pending-gap finality (event-dispatcher.ts's
6
+ // processConsumer). pg_current_snapshot() is this transaction's MVCC view;
7
+ // xmin is the oldest still-in-progress xact id in it, xmax the next
8
+ // unassigned one. Both travel as strings — bigint/xid8 doesn't round-trip
9
+ // through the driver as a JS number.
10
+ export async function selectSnapshotXmin(db: AnyDb): Promise<string> {
11
+ const rows = (await asRawClient(db).unsafe(
12
+ `SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`,
13
+ )) as ReadonlyArray<{ xmin: string }>;
14
+ const xmin = rows[0]?.xmin;
15
+ if (xmin === undefined) throw new Error("selectSnapshotXmin: no row returned");
16
+ return xmin;
17
+ }
18
+
19
+ export async function selectSnapshotXmax(db: AnyDb): Promise<string> {
20
+ const rows = (await asRawClient(db).unsafe(
21
+ `SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`,
22
+ )) as ReadonlyArray<{ xmax: string }>;
23
+ const xmax = rows[0]?.xmax;
24
+ if (xmax === undefined) throw new Error("selectSnapshotXmax: no row returned");
25
+ return xmax;
26
+ }
27
+
4
28
  /** Serialise against consumer-bootstrap INSERTs during event retention prune. */
5
29
  export async function lockEventConsumersShareMode(db: AnyDb): Promise<void> {
6
30
  await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
7
31
  }
8
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
+
9
78
  // startFrom "now" seeds the FIRST-registration cursor at the current
10
79
  // MAX(events.id) instead of the column default 0 — mounting a consumer into
11
- // 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.
12
87
  // ON CONFLICT DO NOTHING keeps both branches safe for an existing row: the
13
88
  // subquery only ever affects the row this statement inserts.
14
89
  export async function insertConsumerIfAbsent(
@@ -18,11 +93,18 @@ export async function insertConsumerIfAbsent(
18
93
  startFrom: "beginning" | "now" = "beginning",
19
94
  ): Promise<void> {
20
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);
21
103
  await asRawClient(db).unsafe(
22
- `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id")
23
- 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)
24
106
  ON CONFLICT ("name", "instance_id") DO NOTHING`,
25
- [name, instanceId],
107
+ [name, instanceId, horizon, JSON.stringify(gaps)],
26
108
  );
27
109
  } else {
28
110
  await asRawClient(db).unsafe(
@@ -59,6 +141,37 @@ export async function deleteOrphanedPerInstanceConsumerRows(
59
141
  );
60
142
  }
61
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
+
62
175
  export async function selectConsumerForUpdateSkipLocked(
63
176
  db: AnyDb,
64
177
  name: string,
@@ -116,6 +229,7 @@ export type ConsumerDeliveryOutcome = {
116
229
  readonly lastError: string | null;
117
230
  readonly deadLettered: boolean;
118
231
  readonly processed: number;
232
+ readonly pendingGaps: readonly PendingGapEntry[];
119
233
  };
120
234
 
121
235
  export async function updateConsumerDeliveryOutcome(
@@ -136,14 +250,17 @@ export async function updateConsumerDeliveryOutcome(
136
250
  "status" = $3,
137
251
  "last_error" = $4,
138
252
  "rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
253
+ -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
254
+ "pending_gaps" = $6::text::jsonb,
139
255
  "updated_at" = now()
140
- WHERE "name" = $6 AND "instance_id" = $7`,
256
+ WHERE "name" = $7 AND "instance_id" = $8`,
141
257
  [
142
258
  outcome.cursor,
143
259
  outcome.attempts,
144
260
  outcome.deadLettered ? "dead" : "idle",
145
261
  outcome.lastError,
146
262
  resetRearmCount,
263
+ JSON.stringify(outcome.pendingGaps),
147
264
  name,
148
265
  instanceId,
149
266
  ],
@@ -192,13 +309,14 @@ export async function resetConsumerForMspRebuild(
192
309
  instanceId: string,
193
310
  ): Promise<void> {
194
311
  await asRawClient(db).unsafe(
195
- `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "last_processed_event_id", "status")
196
- VALUES ($1, $2, 0, 'idle')
312
+ `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "last_processed_event_id", "status", "pending_gaps")
313
+ VALUES ($1, $2, 0, 'idle', '[]'::jsonb)
197
314
  ON CONFLICT ("name", "instance_id") DO UPDATE SET
198
315
  "last_processed_event_id" = 0,
199
316
  "status" = 'idle',
200
317
  "attempts" = 0,
201
318
  "last_error" = NULL,
319
+ "pending_gaps" = '[]'::jsonb,
202
320
  "updated_at" = now()`,
203
321
  [name, instanceId],
204
322
  );
@@ -220,6 +338,7 @@ export async function updateConsumerRebuildCursor(
220
338
  name: string,
221
339
  instanceId: string,
222
340
  lastProcessedEventId: bigint,
341
+ pendingGaps: readonly PendingGapEntry[],
223
342
  ): Promise<void> {
224
343
  await asRawClient(db).unsafe(
225
344
  `UPDATE "kumiko_event_consumers" SET
@@ -227,9 +346,11 @@ export async function updateConsumerRebuildCursor(
227
346
  "status" = 'idle',
228
347
  "attempts" = 0,
229
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,
230
351
  "updated_at" = now()
231
- WHERE "name" = $2 AND "instance_id" = $3`,
232
- [lastProcessedEventId, name, instanceId],
352
+ WHERE "name" = $3 AND "instance_id" = $4`,
353
+ [lastProcessedEventId, JSON.stringify(pendingGaps), name, instanceId],
233
354
  );
234
355
  }
235
356
 
@@ -269,3 +390,28 @@ export async function rearmDeadConsumer(
269
390
  )) as ReadonlyArray<Record<string, unknown>>;
270
391
  return rows[0];
271
392
  }
393
+
394
+ // skipPoisonEvent's pending-gap branch: the poison is a pending id below the
395
+ // cursor, so it's removed from pending_gaps directly instead of advancing
396
+ // last_processed_event_id (advancing it here would be a regression — the
397
+ // cursor already sits above this id).
398
+ export async function removePendingGapReturning(
399
+ db: AnyDb,
400
+ name: string,
401
+ instanceId: string,
402
+ newPendingGaps: readonly PendingGapEntry[],
403
+ ): Promise<Record<string, unknown> | undefined> {
404
+ const rows = (await asRawClient(db).unsafe(
405
+ `UPDATE "kumiko_event_consumers" SET
406
+ "pending_gaps" = $1::text::jsonb,
407
+ "status" = 'idle',
408
+ "attempts" = 0,
409
+ "last_error" = NULL,
410
+ "rearm_count" = 0,
411
+ "updated_at" = now()
412
+ WHERE "name" = $2 AND "instance_id" = $3
413
+ RETURNING *`,
414
+ [JSON.stringify(newPendingGaps), name, instanceId],
415
+ )) as ReadonlyArray<Record<string, unknown>>;
416
+ return rows[0];
417
+ }
@@ -7,6 +7,15 @@ import {
7
7
  import type { AnyDb } from "../query";
8
8
  import { asRawClient, unsafeReadRetrying } from "../query";
9
9
 
10
+ // Gap-finality (event-dispatcher pending_gaps) needs every holder of an
11
+ // event id to already have a *real* xact id by the time it inserts — Postgres
12
+ // assigns those lazily on first write, so a bare INSERT alone doesn't
13
+ // guarantee one exists yet for comparison against a later snapshot's
14
+ // xmin/xmax. pg_current_xact_id() forces the allocation.
15
+ export async function claimXactId(db: AnyDb): Promise<void> {
16
+ await asRawClient(db).unsafe(`SELECT pg_current_xact_id()`);
17
+ }
18
+
10
19
  /** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
11
20
  export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void> {
12
21
  await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
@@ -283,3 +292,63 @@ export async function upsertArchivedStream(db: AnyDb, params: ArchiveStreamParam
283
292
  [params.tenantId, params.aggregateId, params.aggregateType, params.archivedBy, params.reason],
284
293
  );
285
294
  }
295
+
296
+ export type PendingIdRange = { readonly from: bigint; readonly to: bigint };
297
+
298
+ // Per-consumer turn fetch: the plain `id > cursor` window plus any ranges the
299
+ // consumer is still watching as pending gaps (ids invisible on an earlier
300
+ // turn that may have become visible since). Raw SQL — the typed builder's
301
+ // WhereObject is an AND of fields, it can't express this OR.
302
+ export async function selectPendingAndNewEventRows(
303
+ db: AnyDb,
304
+ cursor: bigint,
305
+ pendingRanges: readonly PendingIdRange[],
306
+ batchSize: number,
307
+ ): Promise<ReadonlyArray<Record<string, unknown>>> {
308
+ if (pendingRanges.length === 0) {
309
+ return unsafeReadRetrying(
310
+ db,
311
+ `SELECT * FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT $2`,
312
+ [cursor, batchSize],
313
+ );
314
+ }
315
+ return unsafeReadRetrying(
316
+ db,
317
+ // UNION ALL of two primary-key range scans: an `id > $1 OR EXISTS (…)`
318
+ // predicate can't use the index and would seq-scan kumiko_events per turn.
319
+ // Ranges sit below $1, so the branches never overlap.
320
+ `(SELECT e.* FROM unnest($2::bigint[], $3::bigint[]) AS g(f, t)
321
+ CROSS JOIN LATERAL (
322
+ SELECT * FROM "kumiko_events" WHERE "id" BETWEEN g.f AND g.t ORDER BY "id" ASC LIMIT $4
323
+ ) e)
324
+ UNION ALL
325
+ (SELECT * FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT $4)
326
+ ORDER BY "id" ASC LIMIT $4`,
327
+ [
328
+ cursor,
329
+ pendingRanges.map((r) => r.from.toString()),
330
+ pendingRanges.map((r) => r.to.toString()),
331
+ batchSize,
332
+ ],
333
+ );
334
+ }
335
+
336
+ // Smallest committed-and-visible id inside any pending range — lets
337
+ // skipPoisonEvent (event-dispatcher-admin.ts) tell a live poison apart from
338
+ // a still-invisible gap without pulling full rows.
339
+ export async function selectSmallestVisibleIdInRanges(
340
+ db: AnyDb,
341
+ pendingRanges: readonly PendingIdRange[],
342
+ ): Promise<bigint | null> {
343
+ if (pendingRanges.length === 0) return null;
344
+ const rows = (await unsafeReadRetrying(
345
+ db,
346
+ `SELECT MIN("id")::text AS id FROM "kumiko_events"
347
+ WHERE EXISTS (
348
+ SELECT 1 FROM unnest($1::bigint[], $2::bigint[]) AS g(f, t) WHERE "id" BETWEEN g.f AND g.t
349
+ )`,
350
+ [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())],
351
+ )) as ReadonlyArray<{ id: string | null }>;
352
+ const id = rows[0]?.id;
353
+ return id === null || id === undefined ? null : BigInt(id);
354
+ }