@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
@@ -1,4 +1,4 @@
1
- import type { SseBroker } from "../api/sse-broker";
1
+ import type { AccessInvalidationScope, SseBroker } from "../api/sse-broker";
2
2
  import {
3
3
  collectSearchableSubjectFields,
4
4
  configuredPiiSubjectKms,
@@ -18,6 +18,7 @@ import type {
18
18
  SessionUser,
19
19
  TenantId,
20
20
  } from "../engine/types";
21
+ import type { StoredEvent } from "../event-store";
21
22
  import type { SearchAdapter, SearchDocument } from "../search/types";
22
23
  import type { EventConsumer } from "./event-dispatcher";
23
24
 
@@ -50,11 +51,171 @@ import type { EventConsumer } from "./event-dispatcher";
50
51
  // instead, so the same sensitive+searchable boot-guard (entity-handler.ts)
51
52
  // keeps them out there too.
52
53
  //
53
- // No batch variant right now — every event triggers its own index() call.
54
- // If a scale measurement later calls for it, the event-dispatcher could
55
- // grow a batch-handler variant.
54
+ // batchHandler collapses a turn's events to one search-op per
55
+ // (tenantId, entityType, entityId) and issues one indexBatch/removeBatch
56
+ // call per tenant instead of one round-trip per event; handler stays the
57
+ // per-event fallback the dispatcher falls back to when a batch throws.
56
58
  export const SEARCH_CONSUMER_NAME = "system:consumer:search";
57
59
 
60
+ // #3227 — one resolved search operation for a single event: either the
61
+ // document to (re-)index or the entityType/entityId to remove. A
62
+ // discriminated union so the batch collapse step (last op per key wins)
63
+ // doesn't need to special-case index vs remove.
64
+ export type SearchOperation =
65
+ | { readonly kind: "index"; readonly tenantId: TenantId; readonly doc: SearchDocument }
66
+ | {
67
+ readonly kind: "remove";
68
+ readonly tenantId: TenantId;
69
+ readonly entityType: string;
70
+ readonly entityId: EntityId;
71
+ };
72
+
73
+ // Resolves what one event implies for the search index, without touching
74
+ // the adapter — same decision tree the old per-event handler ran inline.
75
+ async function resolveSearchOperation(
76
+ event: StoredEvent,
77
+ ctx: AppContext,
78
+ registry: Registry,
79
+ ): Promise<SearchOperation | undefined> {
80
+ const entityName = event.aggregateType;
81
+ const verb = event.type.split(".").pop();
82
+ const tenantId = event.tenantId;
83
+
84
+ // skip: delete/forgotten remove the index entry — reconstruct only
85
+ // makes sense for created/updated/restored (field data in payload).
86
+ if (verb === "deleted" || verb === "forgotten") {
87
+ return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
88
+ }
89
+
90
+ let state: Record<string, unknown>;
91
+ if (verb === "created" || verb === "updated" || verb === "restored") {
92
+ state = reconstructStateForSearch(event.payload, verb);
93
+ } else {
94
+ // #2765 — named domain event: no fixed payload shape to reconstruct
95
+ // state from. Only worth a projection read when the entity actually
96
+ // declared searchable fields/extensions — same check
97
+ // buildSearchDocument makes below, done here first so a non-
98
+ // searchable entity's named events stay a no-op like before.
99
+ const entity = registry.getEntity(entityName);
100
+ const isSearchable =
101
+ entity !== undefined &&
102
+ (registry.getSearchableFields(entityName).length > 0 ||
103
+ registry.getSearchPayloadExtensions(entityName).length > 0);
104
+ // skip: entity declares no searchable fields/extensions — nothing to
105
+ // index, stays a no-op with no extra query, same as before #2765.
106
+ if (!entity || !isSearchable) return undefined;
107
+
108
+ const row = await readProjectionRowForSearch(
109
+ ctx,
110
+ registry,
111
+ entityName,
112
+ entity,
113
+ tenantId,
114
+ event.aggregateId,
115
+ );
116
+ // skip: no live row (hard-deleted stream, or soft-deleted) — same
117
+ // outcome as the deleted/forgotten branch above.
118
+ if (!row) {
119
+ return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
120
+ }
121
+ state = row;
122
+ }
123
+
124
+ state = await decryptSearchableSubjectFields(entityName, state, registry);
125
+ // skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
126
+ if (hasErasedSearchableSubjectField(entityName, state, registry)) {
127
+ return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
128
+ }
129
+ const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
130
+ // skip: entity isn't searchable (no searchable fields declared)
131
+ if (!doc) return undefined;
132
+ return { kind: "index", tenantId, doc };
133
+ }
134
+
135
+ async function applySearchOperation(
136
+ searchAdapter: SearchAdapter,
137
+ op: SearchOperation,
138
+ ): Promise<void> {
139
+ if (op.kind === "index") {
140
+ await searchAdapter.index(op.tenantId, op.doc);
141
+ } else {
142
+ await searchAdapter.remove(op.tenantId, op.entityType, op.entityId);
143
+ }
144
+ }
145
+
146
+ // Collapse key for one search op — same (tenant, entity) target as the old
147
+ // per-event index()/remove() calls addressed, index and remove share the
148
+ // key so a later op in the same turn always overwrites an earlier one
149
+ // (update-then-delete ⇒ remove wins, delete-then-restore ⇒ index wins).
150
+ function searchOperationKey(op: SearchOperation): string {
151
+ const entityType = op.kind === "index" ? op.doc.entityType : op.entityType;
152
+ const entityId = op.kind === "index" ? op.doc.entityId : op.entityId;
153
+ return `${op.tenantId}\u0000${entityType}\u0000${String(entityId)}`;
154
+ }
155
+
156
+ type SearchOperationsByTenant = Map<
157
+ TenantId,
158
+ { toIndex: SearchDocument[]; toRemove: { entityType: string; entityId: EntityId }[] }
159
+ >;
160
+
161
+ // Resolves every event's op sequentially (the named-event path does DB
162
+ // reads, so resolving in parallel would fan out uncapped concurrent
163
+ // queries for one turn) and collapses to the last op per
164
+ // (tenantId, entityType, entityId) — update-then-delete ⇒ remove wins,
165
+ // delete-then-restore ⇒ index wins.
166
+ async function resolveSearchOperationsByTenant(
167
+ events: readonly StoredEvent[],
168
+ ctx: AppContext,
169
+ registry: Registry,
170
+ ): Promise<SearchOperationsByTenant> {
171
+ const byKey = new Map<string, SearchOperation>();
172
+ for (const event of events) {
173
+ const op = await resolveSearchOperation(event, ctx, registry);
174
+ if (!op) continue;
175
+ byKey.set(searchOperationKey(op), op);
176
+ }
177
+
178
+ const perTenant: SearchOperationsByTenant = new Map();
179
+ for (const op of byKey.values()) {
180
+ let bucket = perTenant.get(op.tenantId);
181
+ if (!bucket) {
182
+ bucket = { toIndex: [], toRemove: [] };
183
+ perTenant.set(op.tenantId, bucket);
184
+ }
185
+ if (op.kind === "index") bucket.toIndex.push(op.doc);
186
+ else bucket.toRemove.push({ entityType: op.entityType, entityId: op.entityId });
187
+ }
188
+ return perTenant;
189
+ }
190
+
191
+ // Flushes one tenant's collapsed ops — indexBatch/removeBatch when the
192
+ // adapter implements them, otherwise falls back to looping the single-doc
193
+ // methods for that group. Keys were already made disjoint by the collapse
194
+ // in resolveSearchOperationsByTenant, so index-vs-remove order here
195
+ // doesn't matter.
196
+ async function flushSearchOperationsForTenant(
197
+ searchAdapter: SearchAdapter,
198
+ tenantId: TenantId,
199
+ bucket: { toIndex: SearchDocument[]; toRemove: { entityType: string; entityId: EntityId }[] },
200
+ ): Promise<void> {
201
+ if (bucket.toIndex.length > 0) {
202
+ if (searchAdapter.indexBatch) {
203
+ await searchAdapter.indexBatch(tenantId, bucket.toIndex);
204
+ } else {
205
+ for (const doc of bucket.toIndex) await searchAdapter.index(tenantId, doc);
206
+ }
207
+ }
208
+ if (bucket.toRemove.length > 0) {
209
+ if (searchAdapter.removeBatch) {
210
+ await searchAdapter.removeBatch(tenantId, bucket.toRemove);
211
+ } else {
212
+ for (const item of bucket.toRemove) {
213
+ await searchAdapter.remove(tenantId, item.entityType, item.entityId);
214
+ }
215
+ }
216
+ }
217
+ }
218
+
58
219
  export function createSearchEventConsumer(
59
220
  searchAdapter: SearchAdapter,
60
221
  registry: Registry,
@@ -69,64 +230,14 @@ export function createSearchEventConsumer(
69
230
  // dead-lettering in event-dispatcher-delivery.ts if 2min isn't enough.
70
231
  errorPolicy: { maxAttempts: 1200 },
71
232
  handler: async (event, ctx) => {
72
- const entityName = event.aggregateType;
73
- const verb = event.type.split(".").pop();
74
- const tenantId = event.tenantId;
75
-
76
- // skip: delete/forgotten remove the index entry — reconstruct only
77
- // makes sense for created/updated/restored (field data in payload).
78
- if (verb === "deleted" || verb === "forgotten") {
79
- await searchAdapter.remove(tenantId, entityName, event.aggregateId);
80
- return;
81
- }
82
-
83
- let state: Record<string, unknown>;
84
- if (verb === "created" || verb === "updated" || verb === "restored") {
85
- state = reconstructStateForSearch(event.payload, verb);
86
- } else {
87
- // #2765 — named domain event: no fixed payload shape to reconstruct
88
- // state from. Only worth a projection read when the entity actually
89
- // declared searchable fields/extensions — same check
90
- // buildSearchDocument makes below, done here first so a non-
91
- // searchable entity's named events stay a no-op like before.
92
- const entity = registry.getEntity(entityName);
93
- const isSearchable =
94
- entity !== undefined &&
95
- (registry.getSearchableFields(entityName).length > 0 ||
96
- registry.getSearchPayloadExtensions(entityName).length > 0);
97
- // skip: entity declares no searchable fields/extensions — nothing to
98
- // index, stays a no-op with no extra query, same as before #2765.
99
- if (!entity || !isSearchable) return;
100
-
101
- const row = await readProjectionRowForSearch(
102
- ctx,
103
- registry,
104
- entityName,
105
- entity,
106
- tenantId,
107
- event.aggregateId,
108
- );
109
- // skip: no live row (hard-deleted stream, or soft-deleted) — same
110
- // outcome as the deleted/forgotten branch above.
111
- if (!row) {
112
- await searchAdapter.remove(tenantId, entityName, event.aggregateId);
113
- return;
114
- }
115
- state = row;
116
- }
117
-
118
- state = await decryptSearchableSubjectFields(entityName, state, registry);
119
- // skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
120
- if (hasErasedSearchableSubjectField(entityName, state, registry)) {
121
- await searchAdapter.remove(tenantId, entityName, event.aggregateId);
122
- return;
123
- }
124
- const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
125
- if (!doc) {
126
- // skip: entity isn't searchable (no searchable fields declared)
127
- return;
233
+ const op = await resolveSearchOperation(event, ctx, registry);
234
+ if (op) await applySearchOperation(searchAdapter, op);
235
+ },
236
+ batchHandler: async (events, ctx) => {
237
+ const perTenant = await resolveSearchOperationsByTenant(events, ctx, registry);
238
+ for (const [tenantId, bucket] of perTenant) {
239
+ await flushSearchOperationsForTenant(searchAdapter, tenantId, bucket);
128
240
  }
129
- await searchAdapter.index(tenantId, doc);
130
241
  },
131
242
  };
132
243
  }
@@ -451,11 +562,19 @@ export function createJobTriggerEventConsumer(
451
562
  //
452
563
  // "sessions:event:session-revoked" — payload.userId direct (own aggregate,
453
564
  // see bundled-features/sessions/session-revoked-event.ts). Fired for
454
- // both self-service revoke and the privileged cross-tenant
455
- // revoke-all-for-user (SYSTEM_TENANT_ID-anchored DSGVO Art.18 freeze).
565
+ // self-service revoke, the privileged cross-tenant revoke-all-for-user
566
+ // (SYSTEM_TENANT_ID-anchored DSGVO Art.18 freeze), and the raw
567
+ // sessionRevoker callback (logout / tenant-switch — the only source
568
+ // that also sets payload.streamScope, see readSessionRevokedScope below).
456
569
  // fetchPendingEvents has no tenant predicate, so the SYSTEM_TENANT_ID-
457
570
  // anchored event reaches this consumer the same as any other — routing
458
571
  // is purely on payload.userId, never on event.tenantId.
572
+ // "personal-access-tokens:event:pat-revoked" — payload.userId direct (own
573
+ // aggregate, see bundled-features/personal-access-tokens/pat-revoked-event.ts).
574
+ // Fired by both the self-service revoke handler and
575
+ // revokeAllPatTokensForUser (password-change / MFA-toggle / DSGVO
576
+ // forget-subject). Always scoped to payload.tokenIds — a PAT revoke
577
+ // never touches another credential's stream.
459
578
  // "tenant-membership.updated" / "tenant-membership.deleted" — role change
460
579
  // or member removal. userId isn't in payload.changes (update only
461
580
  // carries the changed fields, e.g. { roles }) so it's read from
@@ -480,6 +599,7 @@ export function createJobTriggerEventConsumer(
480
599
  export const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
481
600
 
482
601
  const SESSION_REVOKED_EVENT_TYPE = "sessions:event:session-revoked";
602
+ const PAT_REVOKED_EVENT_TYPE = "personal-access-tokens:event:pat-revoked";
483
603
  const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
484
604
  const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
485
605
 
@@ -490,6 +610,40 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
490
610
  return typeof userId === "string" && userId.length > 0 ? userId : undefined;
491
611
  }
492
612
 
613
+ function readNonEmptyStringArray(value: unknown): string[] | undefined {
614
+ if (!Array.isArray(value) || value.length === 0) return undefined;
615
+ return value.every((v): v is string => typeof v === "string") ? value : undefined;
616
+ }
617
+
618
+ // session-revoked's scope resolution, in order:
619
+ // 1. streamScope === "revoked-sessions" with a non-empty sessionIds array
620
+ // (only the raw sessionRevoker callback sets this) → narrow "sessions"
621
+ // scope, closes exactly those sids.
622
+ // 2. Otherwise, a non-empty keptSessionId ("revoke all others") → spares
623
+ // that one sid, same as before streamScope existed.
624
+ // 3. Otherwise → userwide. Events appended before streamScope existed have
625
+ // neither field and fall straight through to this same userwide default
626
+ // they always had — a replay is unaffected.
627
+ function readSessionRevokedScope(payload: Record<string, unknown>): AccessInvalidationScope {
628
+ if (payload["streamScope"] === "revoked-sessions") {
629
+ const sessionIds = readNonEmptyStringArray(payload["sessionIds"]);
630
+ if (sessionIds) return { kind: "sessions", sessionIds };
631
+ }
632
+ const keptSessionId = payload["keptSessionId"];
633
+ if (typeof keptSessionId === "string" && keptSessionId.length > 0) {
634
+ return { kind: "all-except-session", keptSessionId };
635
+ }
636
+ return { kind: "user" };
637
+ }
638
+
639
+ // pat-revoked is always narrow — a PAT revoke never has a reason to close
640
+ // another credential's stream. Malformed tokenIds fail closed to userwide
641
+ // rather than silently dropping the invalidation.
642
+ function readPatRevokedScope(payload: Record<string, unknown>): AccessInvalidationScope {
643
+ const tokenIds = readNonEmptyStringArray(payload["tokenIds"]);
644
+ return tokenIds ? { kind: "pat-tokens", tokenIds } : { kind: "user" };
645
+ }
646
+
493
647
  export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
494
648
  return {
495
649
  name: ACCESS_INVALIDATION_CONSUMER_NAME,
@@ -511,7 +665,15 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
511
665
  // poison would otherwise permanently stop access-invalidation for
512
666
  // every user behind one bad row).
513
667
  if (typeof userId !== "string" || userId.length === 0) return;
514
- sseBroker.publishAccessInvalidation(userId);
668
+ sseBroker.publishAccessInvalidation(userId, readSessionRevokedScope(event.payload));
669
+ }
670
+
671
+ if (event.type === PAT_REVOKED_EVENT_TYPE) {
672
+ const userId = event.payload["userId"];
673
+ // skip: malformed pat-revoked payload — same fail-open reasoning as
674
+ // session-revoked above.
675
+ if (typeof userId !== "string" || userId.length === 0) return;
676
+ sseBroker.publishAccessInvalidation(userId, readPatRevokedScope(event.payload));
515
677
  }
516
678
 
517
679
  if (
@@ -1,6 +1,8 @@
1
1
  // The static check in boot-validator/access-declarations.ts only sees a handler's own
2
2
  // input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
3
3
  // tables are only visible at the actual write, so the gate runs there at runtime.
4
+ import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
5
+ import * as z from "zod";
4
6
  import { buildEntityTable } from "../db/table-builder";
5
7
  import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
6
8
  import {
@@ -15,12 +17,29 @@ import { AccessDeniedError } from "../errors";
15
17
  import { FrameworkReasons } from "../errors/reasons";
16
18
  import { toSnakeCase } from "../utils/case";
17
19
 
18
- export type WriteOrigin = {
19
- readonly rootHandler: string;
20
- readonly anonymousRoot: boolean;
21
- // Only a write-handler root can declare public-intake; a query/stream root never does.
22
- readonly publicIntake: boolean;
23
- };
20
+ export { isPersonalDataGated, type WriteOrigin };
21
+
22
+ // Only narrows: an inherited origin can add a gate, never lift the root's.
23
+ export function effectiveWriteOrigin(root: WriteOrigin, inherited?: WriteOrigin): WriteOrigin {
24
+ if (!inherited) return root;
25
+ if (isPersonalDataGated(root)) return root;
26
+ if (isPersonalDataGated(inherited)) return inherited;
27
+ return root;
28
+ }
29
+
30
+ const writeOriginSchema = z
31
+ .object({
32
+ rootHandler: z.string(),
33
+ anonymousRoot: z.boolean(),
34
+ publicIntake: z.boolean(),
35
+ viaJob: z.string().optional(),
36
+ })
37
+ .strict();
38
+
39
+ export function parseWriteOrigin(value: unknown): WriteOrigin | undefined {
40
+ const parsed = writeOriginSchema.safeParse(value);
41
+ return parsed.success ? parsed.data : undefined;
42
+ }
24
43
 
25
44
  function declaresPublicIntake(access: AccessRule): boolean {
26
45
  return accessAllowsAnonymous(access) && declaredPersonalData(access) === "public-intake";
@@ -67,7 +86,7 @@ function personalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlyS
67
86
  }
68
87
 
69
88
  // Names fields only, never values: the error reaches the anonymous HTTP caller.
70
- function publicIntakeRequiredError(
89
+ export function publicIntakeRequiredError(
71
90
  origin: WriteOrigin,
72
91
  target: string,
73
92
  fields: readonly string[],
@@ -75,14 +94,16 @@ function publicIntakeRequiredError(
75
94
  return new AccessDeniedError({
76
95
  message:
77
96
  `Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
78
- `${fields.map((f) => `"${f}"`).join(", ")} on "${target}". Declare ` +
79
- 'access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
97
+ `${fields.map((f) => `"${f}"`).join(", ")} on "${target}"` +
98
+ (origin.viaJob ? ` via job "${origin.viaJob}"` : "") +
99
+ '. Declare access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
80
100
  `"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
81
101
  details: {
82
102
  reason: FrameworkReasons.publicIntakeRequired,
83
103
  rootHandler: origin.rootHandler,
84
104
  target,
85
105
  fields,
106
+ ...(origin.viaJob !== undefined && { job: origin.viaJob }),
86
107
  },
87
108
  });
88
109
  }
@@ -93,7 +114,7 @@ export function buildPersonalDataGate(
93
114
  registry: Registry,
94
115
  origin: WriteOrigin,
95
116
  ): PersonalDataGate | undefined {
96
- if (!origin.anonymousRoot || origin.publicIntake) return undefined;
117
+ if (!isPersonalDataGated(origin)) return undefined;
97
118
  const map = personalDataTableMap(registry);
98
119
  return (tableName, keys, entity) => {
99
120
  const personalFields = entity ? personalColumnNames(entity) : map.get(tableName);
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { defineFeature, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { z } from "zod";
3
+ import * as z from "zod";
4
4
  import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
5
5
 
6
6
  // Full-stack L3 proof: a handler with `rateLimit` opt-in is gated by the
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createEmbeddedField,
4
+ createEntity,
5
+ createRegistry,
6
+ createTextField,
7
+ defineFeature,
8
+ } from "../../engine";
9
+ import { deriveSearchAdapterConfig } from "../derive-search-adapter-config";
10
+
11
+ describe("deriveSearchAdapterConfig", () => {
12
+ test("returns undefined for an empty registry", () => {
13
+ expect(deriveSearchAdapterConfig(createRegistry([]))).toBeUndefined();
14
+ });
15
+
16
+ test("returns undefined when no entity has a searchable field", () => {
17
+ const feature = defineFeature("plain", (r) => {
18
+ r.entity(
19
+ "note",
20
+ createEntity({
21
+ table: "notes",
22
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
23
+ }),
24
+ );
25
+ });
26
+
27
+ expect(deriveSearchAdapterConfig(createRegistry([feature]))).toBeUndefined();
28
+ });
29
+
30
+ test("unions searchable fields across entities, deduplicated, in first-occurrence order", () => {
31
+ const feature = defineFeature("crm", (r) => {
32
+ r.entity(
33
+ "customer",
34
+ createEntity({
35
+ table: "customers",
36
+ fields: {
37
+ title: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
38
+ email: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
39
+ },
40
+ }),
41
+ );
42
+ r.entity(
43
+ "order",
44
+ createEntity({
45
+ table: "orders",
46
+ fields: {
47
+ title: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
48
+ note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
49
+ address: createEmbeddedField(
50
+ { city: { type: "text", searchable: true } },
51
+ { personal: false, reason: "test_fixture" },
52
+ ),
53
+ },
54
+ }),
55
+ );
56
+ });
57
+
58
+ const config = deriveSearchAdapterConfig(createRegistry([feature]));
59
+
60
+ expect(config).toEqual({
61
+ searchableFields: ["title", "email", "note", "address_city"],
62
+ rankingFields: ["title", "email", "note", "address_city"],
63
+ });
64
+ });
65
+ });
@@ -255,3 +255,94 @@ describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
255
255
  });
256
256
  });
257
257
  });
258
+
259
+ // Lazy tenant-index configuration off setDefaultConfig, with no
260
+ // explicit per-tenant configure() call from the app.
261
+ describe.skipIf(!MEILI_UP)("meilisearch adapter — lazy default config", () => {
262
+ let lazyClient: Meilisearch;
263
+ let lazyPrefix: string;
264
+
265
+ beforeAll(() => {
266
+ lazyClient = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
267
+ lazyPrefix = `test_lazy_${uuid()}_`;
268
+ });
269
+
270
+ afterAll(async () => {
271
+ const indices = await lazyClient.getIndexes();
272
+ for (const idx of indices.results) {
273
+ if (idx.uid.startsWith(lazyPrefix)) {
274
+ try {
275
+ await lazyClient.index(idx.uid).delete().waitTask();
276
+ } catch {
277
+ /* ok */
278
+ }
279
+ }
280
+ }
281
+ });
282
+
283
+ test("first access via index() configures the tenant index off the default config", async () => {
284
+ const tenant = uuid();
285
+ const lazyAdapter = createMeilisearchAdapter({
286
+ url: MEILI_URL,
287
+ apiKey: MEILI_KEY,
288
+ indexPrefix: lazyPrefix,
289
+ });
290
+ lazyAdapter.setDefaultConfig?.({ searchableFields: ["firstName"] });
291
+
292
+ await lazyAdapter.index(tenant, {
293
+ entityType: "user",
294
+ entityId: 1,
295
+ weight: 1,
296
+ fields: { firstName: "Lazy" },
297
+ });
298
+
299
+ // Mutation-check target: without ensureConfigured() in index(), Meili
300
+ // auto-creates the index on addDocuments but never sets filterable/
301
+ // searchable attributes — assert those directly instead of relying on
302
+ // adapter.search(), which would itself lazily configure the index and
303
+ // mask the mutation.
304
+ const index = lazyClient.index(meilisearchTenantIndex(lazyPrefix, tenant));
305
+ expect(await index.getFilterableAttributes()).toEqual(
306
+ expect.arrayContaining(["_type", "_weight"]),
307
+ );
308
+ expect(await index.getSearchableAttributes()).toEqual(["firstName"]);
309
+
310
+ const results = await lazyAdapter.search(tenant, "lazy", { filterType: "user" });
311
+ expect(results.some((r) => r.entityId === 1 && r.entityType === "user")).toBe(true);
312
+ });
313
+
314
+ test("first access via search() with filterType on a never-written tenant returns empty, not an error", async () => {
315
+ const tenant = uuid();
316
+ const lazyAdapter = createMeilisearchAdapter({
317
+ url: MEILI_URL,
318
+ apiKey: MEILI_KEY,
319
+ indexPrefix: lazyPrefix,
320
+ });
321
+ lazyAdapter.setDefaultConfig?.({ searchableFields: ["firstName"] });
322
+
323
+ const results = await lazyAdapter.search(tenant, "anything", { filterType: "user" });
324
+ expect(results).toEqual([]);
325
+ });
326
+
327
+ test("explicit configure() wins over a later setDefaultConfig() — never overwritten", async () => {
328
+ const tenant = uuid();
329
+ const lazyAdapter = createMeilisearchAdapter({
330
+ url: MEILI_URL,
331
+ apiKey: MEILI_KEY,
332
+ indexPrefix: lazyPrefix,
333
+ });
334
+
335
+ await lazyAdapter.configure(tenant, { searchableFields: ["a"] });
336
+ lazyAdapter.setDefaultConfig?.({ searchableFields: ["b"] });
337
+
338
+ await lazyAdapter.index(tenant, {
339
+ entityType: "user",
340
+ entityId: 1,
341
+ weight: 1,
342
+ fields: { a: "value" },
343
+ });
344
+
345
+ const index = lazyClient.index(meilisearchTenantIndex(lazyPrefix, tenant));
346
+ expect(await index.getSearchableAttributes()).toEqual(["a"]);
347
+ });
348
+ });