@cosmicdrift/kumiko-framework 0.307.0 → 0.309.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 (174) hide show
  1. package/package.json +5 -5
  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/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
  31. package/src/bun-db/connection.ts +2 -0
  32. package/src/changes.json +61 -0
  33. package/src/compliance/override-schema.ts +1 -1
  34. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  35. package/src/crypto/kms-adapter.ts +1 -1
  36. package/src/db/__tests__/feature-table-sources.test.ts +2 -2
  37. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  38. package/src/db/collect-table-metas.ts +3 -6
  39. package/src/db/feature-table-sources.ts +3 -4
  40. package/src/db/queries/event-consumer.ts +98 -6
  41. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  42. package/src/derivatives/delete-stored-file.ts +86 -0
  43. package/src/derivatives/index.ts +2 -0
  44. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  50. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  51. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  52. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  53. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  54. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  55. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  56. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  57. package/src/engine/__tests__/engine.test.ts +1 -1
  58. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  59. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  60. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  61. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  62. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  63. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  65. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  66. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  67. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  68. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  69. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  70. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  71. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  72. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  73. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  74. package/src/engine/__tests__/registry.test.ts +1 -1
  75. package/src/engine/__tests__/screen.test.ts +1 -1
  76. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  79. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  80. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  81. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  82. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  83. package/src/engine/boot-validator/screens.ts +83 -0
  84. package/src/engine/define-handler.ts +2 -1
  85. package/src/engine/entity-handlers.ts +2 -1
  86. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  87. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  88. package/src/engine/feature-builder-state.ts +2 -1
  89. package/src/engine/feature-config-events-jobs.ts +2 -1
  90. package/src/engine/feature-entity-handlers.ts +2 -1
  91. package/src/engine/schema-builder.ts +1 -1
  92. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  93. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  94. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  95. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  96. package/src/env/__tests__/dry-run.test.ts +1 -1
  97. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  98. package/src/env/_zod-introspect.ts +1 -1
  99. package/src/env/dry-run.ts +1 -1
  100. package/src/env/index.ts +1 -1
  101. package/src/errors/__tests__/classes.test.ts +1 -1
  102. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  103. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  104. package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
  105. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  108. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  111. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  112. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  113. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  114. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  115. package/src/observability/metrics-wiring.ts +1 -1
  116. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  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-db-outage.integration.test.ts +192 -0
  129. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  130. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  131. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  132. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  133. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  134. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  135. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  138. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  140. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  141. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  143. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  149. package/src/pipeline/dispatch-stream.ts +6 -1
  150. package/src/pipeline/event-dispatcher-delivery.ts +132 -37
  151. package/src/pipeline/event-dispatcher.ts +121 -17
  152. package/src/pipeline/index.ts +1 -0
  153. package/src/pipeline/msp-rebuild.ts +39 -1
  154. package/src/pipeline/pending-gap-ranges.ts +48 -0
  155. package/src/pipeline/system-hooks.ts +223 -68
  156. package/src/pipeline/write-origin.ts +1 -1
  157. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  158. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  159. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  160. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  161. package/src/search/derive-search-adapter-config.ts +27 -0
  162. package/src/search/index.ts +1 -0
  163. package/src/search/meilisearch-adapter.ts +96 -14
  164. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  165. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  166. package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
  167. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  168. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  169. package/src/stack/test-stack.ts +61 -54
  170. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  171. package/src/testing/__tests__/real-providers.test.ts +9 -1
  172. package/src/testing/e2e-generator.ts +1 -1
  173. package/src/testing/index.ts +1 -1
  174. package/src/testing/real-providers.ts +12 -4
@@ -0,0 +1,306 @@
1
+ // #3227 — the search consumer's batchHandler against a real Meilisearch:
2
+ // N created events collapse into one indexBatch task, and same-turn
3
+ // collisions on one aggregate resolve to the LAST op (update-then-delete
4
+ // removes, delete-then-restore indexes the restored state).
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { generateId as uuid } from "@cosmicdrift/kumiko-framework/utils";
7
+ import { Meilisearch } from "meilisearch";
8
+ import * as z from "zod";
9
+ import { createEntity, createTextField, defineFeature } from "../../engine";
10
+ import type { ConsumerStateRow, StoredEventRow } from "../../pipeline/event-dispatcher-delivery";
11
+ import { deliverEvents } from "../../pipeline/event-dispatcher-delivery";
12
+ import { createSearchEventConsumer } from "../../pipeline/system-hooks";
13
+ import { setupTestStack, type TestStack } from "../../stack";
14
+ import { createMeilisearchAdapter, meilisearchTenantIndex } from "../meilisearch-adapter";
15
+ import type { SearchAdapter } from "../types";
16
+
17
+ const MEILI_URL = process.env["MEILI_URL"] ?? "http://localhost:17700";
18
+ const MEILI_KEY = process.env["MEILI_MASTER_KEY"] ?? "kumiko-dev-key";
19
+
20
+ async function meiliAvailable(): Promise<boolean> {
21
+ try {
22
+ const health = await fetch(`${MEILI_URL}/health`, { signal: AbortSignal.timeout(2000) });
23
+ return health.ok;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ const MEILI_UP = await meiliAvailable();
30
+ if (!MEILI_UP) {
31
+ if (process.env["REQUIRE_MEILI"] === "1") {
32
+ throw new Error(
33
+ `REQUIRE_MEILI=1 but Meilisearch not reachable at ${MEILI_URL} — integration job misconfigured`,
34
+ );
35
+ }
36
+ console.warn(
37
+ `Meilisearch not reachable at ${MEILI_URL} — skipping live batch-consumer tests ` +
38
+ `(docker compose up -d meilisearch, default port 17700)`,
39
+ );
40
+ }
41
+
42
+ const docEntity = createEntity({
43
+ table: "read_batch_search_docs",
44
+ fields: {
45
+ label: createTextField({
46
+ personal: false,
47
+ reason: "test_fixture",
48
+ required: true,
49
+ maxLength: 100,
50
+ searchable: true,
51
+ }),
52
+ },
53
+ });
54
+
55
+ const batchSearchFeature = defineFeature("batch-search", (r) => {
56
+ r.entity("doc", docEntity);
57
+ r.defineEvent("noop", z.object({}), { piiFields: "none" });
58
+ });
59
+
60
+ const TENANT = uuid();
61
+
62
+ let client: Meilisearch;
63
+ let indexPrefix: string;
64
+ let meiliAdapter: SearchAdapter;
65
+ let stack: TestStack;
66
+ let consumer: ReturnType<typeof createSearchEventConsumer>;
67
+ let nextId = 1n;
68
+
69
+ function stubState(): ConsumerStateRow {
70
+ return {
71
+ name: "search",
72
+ instanceId: "shared",
73
+ lastProcessedEventId: 0n,
74
+ status: "idle",
75
+ attempts: 0,
76
+ rearmCount: 0,
77
+ pendingGaps: [],
78
+ lastError: null,
79
+ updatedAt: Temporal.Now.instant(),
80
+ };
81
+ }
82
+
83
+ function eventRow(
84
+ aggregateId: string,
85
+ type: "created" | "updated" | "deleted" | "restored",
86
+ payload: Record<string, unknown>,
87
+ ): StoredEventRow {
88
+ return {
89
+ id: nextId++,
90
+ aggregateId,
91
+ aggregateType: "doc",
92
+ tenantId: TENANT,
93
+ version: 1,
94
+ type: `doc.${type}`,
95
+ eventVersion: 1,
96
+ payload,
97
+ metadata: { userId: "system" },
98
+ createdAt: Temporal.Now.instant(),
99
+ createdBy: "system",
100
+ };
101
+ }
102
+
103
+ async function deliverBatch(rows: readonly StoredEventRow[]): Promise<void> {
104
+ const outcome = await deliverEvents(
105
+ consumer,
106
+ rows,
107
+ { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
108
+ 10,
109
+ stubState(),
110
+ );
111
+ if (outcome.failed > 0) throw new Error(`test setup: delivery failed (${outcome.lastError})`);
112
+ }
113
+
114
+ async function taskCountSince(baselineUid: number, types: readonly string[]): Promise<number> {
115
+ const indexUid = meilisearchTenantIndex(indexPrefix, TENANT);
116
+ const page = await client.tasks.getTasks({
117
+ indexUids: [indexUid],
118
+ types: types as ("documentAdditionOrUpdate" | "documentDeletion")[],
119
+ limit: 100,
120
+ });
121
+ return page.results.filter((task) => task.uid > baselineUid).length;
122
+ }
123
+
124
+ async function highestTaskUid(): Promise<number> {
125
+ const indexUid = meilisearchTenantIndex(indexPrefix, TENANT);
126
+ const page = await client.tasks.getTasks({ indexUids: [indexUid], limit: 1 });
127
+ return page.results[0]?.uid ?? -1;
128
+ }
129
+
130
+ describe.skipIf(!MEILI_UP)("search consumer batchHandler (live Meilisearch)", () => {
131
+ beforeAll(async () => {
132
+ client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
133
+ indexPrefix = `test_batch_${uuid()}_`;
134
+ meiliAdapter = createMeilisearchAdapter({ url: MEILI_URL, apiKey: MEILI_KEY, indexPrefix });
135
+
136
+ stack = await setupTestStack({ features: [batchSearchFeature], systemHooks: [] });
137
+ consumer = createSearchEventConsumer(meiliAdapter, stack.registry);
138
+
139
+ await meiliAdapter.configure(TENANT, { searchableFields: ["label"] });
140
+ });
141
+
142
+ afterAll(async () => {
143
+ await stack.cleanup();
144
+ const indices = await client.getIndexes();
145
+ for (const idx of indices.results) {
146
+ if (idx.uid.startsWith(indexPrefix)) {
147
+ try {
148
+ await client.index(idx.uid).delete().waitTask();
149
+ } catch {
150
+ /* ok */
151
+ }
152
+ }
153
+ }
154
+ });
155
+
156
+ test("5 created events for one tenant produce exactly one indexing task and 5 searchable docs", async () => {
157
+ const baseline = await highestTaskUid();
158
+ const rows = Array.from({ length: 5 }, (_, i) =>
159
+ eventRow(uuid(), "created", { label: `batchdoc-${i}` }),
160
+ );
161
+
162
+ await deliverBatch(rows);
163
+
164
+ const indexTasks = await taskCountSince(baseline, ["documentAdditionOrUpdate"]);
165
+ expect(indexTasks).toBe(1);
166
+
167
+ const hits = await meiliAdapter.search(TENANT, "batchdoc", { limit: 20, filterType: "doc" });
168
+ expect(hits.length).toBe(5);
169
+ });
170
+
171
+ test("update-then-delete of the same aggregate in one batch leaves the doc absent", async () => {
172
+ const aggregateId = uuid();
173
+ await deliverBatch([eventRow(aggregateId, "created", { label: "collapse-update-delete" })]);
174
+ expect(
175
+ (await meiliAdapter.search(TENANT, "collapse-update-delete", { filterType: "doc" })).some(
176
+ (h) => h.entityId === aggregateId,
177
+ ),
178
+ ).toBe(true);
179
+
180
+ await deliverBatch([
181
+ eventRow(aggregateId, "updated", {
182
+ previous: { label: "collapse-update-delete" },
183
+ changes: { label: "collapse-update-delete-updated" },
184
+ }),
185
+ eventRow(aggregateId, "deleted", {}),
186
+ ]);
187
+
188
+ const hits = await meiliAdapter.search(TENANT, "collapse-update-delete", {
189
+ filterType: "doc",
190
+ });
191
+ expect(hits.some((h) => h.entityId === aggregateId)).toBe(false);
192
+ });
193
+
194
+ test("delete-then-restore of the same aggregate in one batch indexes the restored state", async () => {
195
+ const aggregateId = uuid();
196
+ await deliverBatch([eventRow(aggregateId, "created", { label: "collapse-delete-restore" })]);
197
+
198
+ await deliverBatch([
199
+ eventRow(aggregateId, "deleted", {}),
200
+ eventRow(aggregateId, "restored", { previous: { label: "collapse-delete-restore-back" } }),
201
+ ]);
202
+
203
+ const hits = await meiliAdapter.search(TENANT, "collapse-delete-restore-back", {
204
+ filterType: "doc",
205
+ });
206
+ expect(hits.some((h) => h.entityId === aggregateId)).toBe(true);
207
+ });
208
+
209
+ test("a batch mixing creates and deletes produces at most one index task and one deletion task", async () => {
210
+ const toDelete = uuid();
211
+ await deliverBatch([eventRow(toDelete, "created", { label: "mixed-batch-to-delete" })]);
212
+
213
+ const baseline = await highestTaskUid();
214
+ await deliverBatch([
215
+ eventRow(uuid(), "created", { label: "mixed-batch-created-1" }),
216
+ eventRow(uuid(), "created", { label: "mixed-batch-created-2" }),
217
+ eventRow(toDelete, "deleted", {}),
218
+ ]);
219
+
220
+ const indexTasks = await taskCountSince(baseline, ["documentAdditionOrUpdate"]);
221
+ const deletionTasks = await taskCountSince(baseline, ["documentDeletion"]);
222
+ expect(indexTasks).toBe(1);
223
+ expect(deletionTasks).toBe(1);
224
+ });
225
+
226
+ test("remove and removeBatch on a tenant whose index was never created resolve", async () => {
227
+ const tenantWithoutIndex = uuid();
228
+ await expect(meiliAdapter.remove(tenantWithoutIndex, "doc", uuid())).resolves.toBeUndefined();
229
+ await expect(
230
+ meiliAdapter.removeBatch?.(tenantWithoutIndex, [{ entityType: "doc", entityId: uuid() }]),
231
+ ).resolves.toBeUndefined();
232
+ });
233
+
234
+ test("indexBatch rejects when one doc in the batch fails on the Meilisearch side", async () => {
235
+ // Meilisearch caps document identifiers at 511 bytes — meilisearchDocId
236
+ // prefixes with `${entityType}_`, so an id this long overflows that
237
+ // limit and the server fails the whole task with invalid_document_id.
238
+ const oversizedId = `${uuid()}${"a".repeat(520)}`;
239
+ await expect(
240
+ meiliAdapter.indexBatch?.(TENANT, [
241
+ { entityType: "doc", entityId: uuid(), weight: 1, fields: { label: "valid-doc" } },
242
+ { entityType: "doc", entityId: oversizedId, weight: 1, fields: { label: "poison-doc" } },
243
+ ]),
244
+ ).rejects.toThrow(/invalid_document_id/);
245
+ });
246
+
247
+ test("a poison doc in the batch halts the turn at the last good event; a second pass dead-letters it", async () => {
248
+ // Real UUID aggregateIds always produce a valid Meili doc id via
249
+ // meilisearchDocId (all illegal characters are sanitized away) — the
250
+ // only realistic way to fail Meili's own id validation is length, so
251
+ // event #3's aggregateId is an oversized synthetic id, not a shape
252
+ // production ever emits.
253
+ const poisonConsumer = {
254
+ ...createSearchEventConsumer(meiliAdapter, stack.registry),
255
+ errorPolicy: { maxAttempts: 2 },
256
+ };
257
+
258
+ const good1 = eventRow(uuid(), "created", { label: "poisondoc-1" });
259
+ const good2 = eventRow(uuid(), "created", { label: "poisondoc-2" });
260
+ const poisonAggregateId = `${uuid()}${"a".repeat(520)}`;
261
+ const poison = eventRow(poisonAggregateId, "created", { label: "poisondoc-3" });
262
+ const good4 = eventRow(uuid(), "created", { label: "poisondoc-4" });
263
+ const good5 = eventRow(uuid(), "created", { label: "poisondoc-5" });
264
+ const rows = [good1, good2, poison, good4, good5];
265
+
266
+ const first = await deliverEvents(
267
+ poisonConsumer,
268
+ rows,
269
+ { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
270
+ 10,
271
+ stubState(),
272
+ );
273
+
274
+ expect(first.cursor).toBe(good2.id);
275
+ expect(first.attempts).toBe(1);
276
+ expect(first.deadLettered).toBe(false);
277
+
278
+ const hitsAfterFirst = await meiliAdapter.search(TENANT, "poisondoc", {
279
+ limit: 20,
280
+ filterType: "doc",
281
+ });
282
+ const foundLabels = new Set(hitsAfterFirst.map((h) => h.entityId));
283
+ expect(foundLabels.has(good1.aggregateId)).toBe(true);
284
+ expect(foundLabels.has(good2.aggregateId)).toBe(true);
285
+ expect(foundLabels.has(poison.aggregateId)).toBe(false);
286
+ expect(foundLabels.has(good4.aggregateId)).toBe(false);
287
+ expect(foundLabels.has(good5.aggregateId)).toBe(false);
288
+
289
+ const second = await deliverEvents(
290
+ poisonConsumer,
291
+ rows.slice(2),
292
+ { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
293
+ 10,
294
+ {
295
+ ...stubState(),
296
+ lastProcessedEventId: first.cursor,
297
+ attempts: first.attempts,
298
+ lastError: first.lastError,
299
+ },
300
+ );
301
+
302
+ expect(second.deadLettered).toBe(true);
303
+ expect(second.attempts).toBe(2);
304
+ expect(second.cursor).toBe(good2.id);
305
+ });
306
+ });
@@ -0,0 +1,27 @@
1
+ import type { Registry } from "../engine/types";
2
+ import type { SearchAdapterConfig } from "./types";
3
+
4
+ // Union of getSearchableFields() across every registered entity, in stable
5
+ // first-occurrence order and deduplicated. Feeds SearchAdapter.setDefaultConfig
6
+ // so the framework can hand the adapter a config derived purely from the
7
+ // registry, without any app-side wiring.
8
+ //
9
+ // searchPayloadExtension keys (e.g. customFields) are dynamic per-tenant and
10
+ // therefore not part of any entity's static searchableFields — they stay out
11
+ // of searchableAttributes here too. Not solved by this change, not worsened.
12
+ export function deriveSearchAdapterConfig(registry: Registry): SearchAdapterConfig | undefined {
13
+ const searchableFields: string[] = [];
14
+ const seen = new Set<string>();
15
+
16
+ for (const entityName of registry.getAllEntities().keys()) {
17
+ for (const field of registry.getSearchableFields(entityName)) {
18
+ if (seen.has(field)) continue;
19
+ seen.add(field);
20
+ searchableFields.push(field);
21
+ }
22
+ }
23
+
24
+ if (searchableFields.length === 0) return undefined;
25
+
26
+ return { searchableFields, rankingFields: searchableFields };
27
+ }
@@ -2,6 +2,7 @@
2
2
  // Damit lädt der Main-Barrel keinen Meilisearch-Client beim bloßen Anfassen
3
3
  // von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
4
4
  // Client-Code nicht mit rein.
5
+ export { deriveSearchAdapterConfig } from "./derive-search-adapter-config";
5
6
  export { createInMemorySearchAdapter } from "./in-memory-adapter";
6
7
  export { purgeSearchDocumentsForSubject } from "./purge-subject";
7
8
  export type {
@@ -1,6 +1,33 @@
1
- import { Meilisearch } from "meilisearch";
1
+ import { type EnqueuedTaskPromise, ErrorStatusCode, Meilisearch, type Task } from "meilisearch";
2
2
  import type { EntityId, TenantId } from "../engine/types/identifiers";
3
- import type { SearchAdapter, SearchResult } from "./types";
3
+ import type { SearchAdapter, SearchAdapterConfig, SearchResult } from "./types";
4
+
5
+ // meilisearch's waitTask() RESOLVES (never rejects) once the server task
6
+ // reaches a terminal state, even "failed" — a rejected doc (e.g. an id over
7
+ // Meili's 511-byte limit) otherwise fails silently and the caller thinks
8
+ // the write succeeded. Route every waitTask() through here so a non-
9
+ // "succeeded" status always throws, except for the error codes a caller
10
+ // declares as an already-reached end state.
11
+ async function awaitSucceededTask(
12
+ enqueued: EnqueuedTaskPromise,
13
+ toleratedErrorCodes: readonly string[] = [],
14
+ ): Promise<Task> {
15
+ const task = await enqueued.waitTask();
16
+ const tolerated = task.error ? toleratedErrorCodes.includes(task.error.code) : false;
17
+ if (task.status !== "succeeded" && !tolerated) {
18
+ const detail = task.error ? `${task.error.code}: ${task.error.message}` : "no error detail";
19
+ throw new Error(
20
+ `Meilisearch task ${task.uid} (${task.type}) ended with status "${task.status}": ${detail}`,
21
+ );
22
+ }
23
+ return task;
24
+ }
25
+
26
+ // A tenant whose index was never created (no default config, nothing ever
27
+ // indexed) has nothing to delete: the remove's end state already holds.
28
+ // Without this, a delete event or a DSGVO subject purge for such a tenant
29
+ // would fail forever.
30
+ const REMOVE_TOLERATED_ERROR_CODES: readonly string[] = [ErrorStatusCode.INDEX_NOT_FOUND];
4
31
 
5
32
  export type MeilisearchAdapterOptions = {
6
33
  url: string;
@@ -23,19 +50,67 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
23
50
  const client = new Meilisearch({ host: options.url, apiKey: options.apiKey });
24
51
  const prefix = options.indexPrefix ?? "kumiko_";
25
52
 
53
+ // Set via setDefaultConfig once the registry is known (server.ts boot).
54
+ // undefined until then — tenants stay unconfigured, matching prior behavior.
55
+ let defaultConfig: SearchAdapterConfig | undefined;
56
+ // Per-tenant configure state. An explicit `configure(tenantId, ...)` call
57
+ // always wins and is never overwritten by the lazy default — its promise
58
+ // sits in the map exactly like a lazily-triggered one.
59
+ const configuredTenants = new Map<TenantId, Promise<void>>();
60
+
61
+ async function applyConfig(tenantId: TenantId, config: SearchAdapterConfig): Promise<void> {
62
+ const index = client.index(meilisearchTenantIndex(prefix, tenantId));
63
+ const fields = config.rankingFields ?? config.searchableFields;
64
+ await awaitSucceededTask(index.updateSearchableAttributes([...fields]));
65
+ await awaitSucceededTask(index.updateFilterableAttributes(["_type", "_weight"]));
66
+ await awaitSucceededTask(index.updateSortableAttributes(["_weight"]));
67
+ }
68
+
69
+ // Lazily configures a tenant's index off the default config on its first
70
+ // access, so callers that never call `configure` still get a filterable/
71
+ // sortable index instead of failing with "not filterable"/"index not
72
+ // found". A failed configure is never memoized — remove the map entry (iff
73
+ // it's still the same in-flight promise) and rethrow, so a transient
74
+ // Meili outage doesn't permanently poison the tenant for the process.
75
+ async function ensureConfigured(tenantId: TenantId): Promise<void> {
76
+ // skip: no registry-derived default — callers configure tenants explicitly.
77
+ if (!defaultConfig) return;
78
+ const existing = configuredTenants.get(tenantId);
79
+ if (existing) {
80
+ await existing;
81
+ // skip: configured (or in flight) already for this tenant.
82
+ return;
83
+ }
84
+ await trackConfigure(tenantId, defaultConfig);
85
+ }
86
+
87
+ async function trackConfigure(tenantId: TenantId, config: SearchAdapterConfig): Promise<void> {
88
+ const pending = applyConfig(tenantId, config);
89
+ configuredTenants.set(tenantId, pending);
90
+ try {
91
+ await pending;
92
+ } catch (err) {
93
+ if (configuredTenants.get(tenantId) === pending) {
94
+ configuredTenants.delete(tenantId);
95
+ }
96
+ throw err;
97
+ }
98
+ }
99
+
26
100
  return {
101
+ setDefaultConfig(config) {
102
+ defaultConfig = config;
103
+ },
104
+
27
105
  async configure(tenantId, config) {
28
- const index = client.index(meilisearchTenantIndex(prefix, tenantId));
29
- const fields = config.rankingFields ?? config.searchableFields;
30
- await index.updateSearchableAttributes([...fields]).waitTask();
31
- await index.updateFilterableAttributes(["_type", "_weight"]).waitTask();
32
- await index.updateSortableAttributes(["_weight"]).waitTask();
106
+ await trackConfigure(tenantId, config);
33
107
  },
34
108
 
35
109
  async index(tenantId, doc) {
110
+ await ensureConfigured(tenantId);
36
111
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
37
- await index
38
- .addDocuments(
112
+ await awaitSucceededTask(
113
+ index.addDocuments(
39
114
  [
40
115
  {
41
116
  _id: meilisearchDocId(doc.entityType, doc.entityId),
@@ -46,13 +121,14 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
46
121
  },
47
122
  ],
48
123
  { primaryKey: "_id" },
49
- )
50
- .waitTask();
124
+ ),
125
+ );
51
126
  },
52
127
 
53
128
  async indexBatch(tenantId, docs) {
54
129
  // skip: empty batch — avoid an unnecessary Meilisearch round-trip
55
130
  if (docs.length === 0) return;
131
+ await ensureConfigured(tenantId);
56
132
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
57
133
  const payload = docs.map((doc) => ({
58
134
  _id: meilisearchDocId(doc.entityType, doc.entityId),
@@ -64,18 +140,20 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
64
140
  // Single Meilisearch task covering all N docs. Meilisearch processes
65
141
  // the payload server-side as one indexing job — waitTask blocks until
66
142
  // that job is done, but it's one round-trip instead of N.
67
- await index.addDocuments(payload, { primaryKey: "_id" }).waitTask();
143
+ await awaitSucceededTask(index.addDocuments(payload, { primaryKey: "_id" }));
68
144
  },
69
145
 
70
146
  async removeBatch(tenantId, items) {
71
147
  // skip: empty batch — avoid an unnecessary Meilisearch round-trip
72
148
  if (items.length === 0) return;
149
+ await ensureConfigured(tenantId);
73
150
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
74
151
  const ids = items.map((i) => meilisearchDocId(i.entityType, i.entityId));
75
- await index.deleteDocuments(ids).waitTask();
152
+ await awaitSucceededTask(index.deleteDocuments(ids), REMOVE_TOLERATED_ERROR_CODES);
76
153
  },
77
154
 
78
155
  async search(tenantId, query, options) {
156
+ await ensureConfigured(tenantId);
79
157
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
80
158
 
81
159
  const filter: string[] = [];
@@ -100,8 +178,12 @@ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): Se
100
178
  },
101
179
 
102
180
  async remove(tenantId, entityType, entityId) {
181
+ await ensureConfigured(tenantId);
103
182
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
104
- await index.deleteDocument(meilisearchDocId(entityType, entityId)).waitTask();
183
+ await awaitSucceededTask(
184
+ index.deleteDocument(meilisearchDocId(entityType, entityId)),
185
+ REMOVE_TOLERATED_ERROR_CODES,
186
+ );
105
187
  },
106
188
  };
107
189
  }
@@ -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 type { TenantId } from "../../engine";
4
4
  import { defineQueryHandler } from "../../engine/define-handler";
5
5
  import { type ContainsSecret, createSecret, type Secret } from "../index";
@@ -4,7 +4,7 @@
4
4
  // stack.http.
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { defineFeature } from "../../engine";
9
9
  import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
10
10
  import { setupTestStack, type TestStack } from "../test-stack";
@@ -0,0 +1,65 @@
1
+ // #3102: setupTestStack pushed projection/MSP/storeTable sources but never
2
+ // r.entity() tables, so any feature relying purely on entities crashed on
3
+ // first write with 42P01 unless the test manually created the table.
4
+
5
+ import { afterAll, describe, expect, test } from "bun:test";
6
+ import { asRawClient } from "../../db/query";
7
+ import { createEntity, createTextField, defineFeature } from "../../engine";
8
+ import { setupTestStack, type TestStack } from "../test-stack";
9
+
10
+ const widgetEntity = createEntity({
11
+ table: "widgets",
12
+ fields: {
13
+ name: createTextField({ personal: false, reason: "test_fixture", required: true }),
14
+ },
15
+ });
16
+
17
+ const gadgetEntity = createEntity({
18
+ table: "gadgets",
19
+ fields: {
20
+ label: createTextField({ personal: false, reason: "test_fixture", required: true }),
21
+ },
22
+ });
23
+
24
+ const widgetFeature = defineFeature("widget", (r) => {
25
+ r.entity("widget", widgetEntity);
26
+ });
27
+
28
+ const twoEntityFeature = defineFeature("gadgetbox", (r) => {
29
+ r.entity("widget", widgetEntity);
30
+ r.entity("gadget", gadgetEntity);
31
+ });
32
+
33
+ async function tableExistsInInformationSchema(
34
+ db: TestStack["db"],
35
+ tableName: string,
36
+ ): Promise<boolean> {
37
+ const rows = await asRawClient(db).unsafe<{ table_name: string }>(
38
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
39
+ [tableName],
40
+ );
41
+ return rows.length === 1;
42
+ }
43
+
44
+ describe("setupTestStack — r.entity() tables", () => {
45
+ let stack: TestStack | undefined;
46
+
47
+ afterAll(async () => {
48
+ await stack?.cleanup();
49
+ });
50
+
51
+ test("creates the backing table for a plain r.entity() without manual table setup", async () => {
52
+ stack = await setupTestStack({ features: [widgetFeature] });
53
+ expect(await tableExistsInInformationSchema(stack.db, "widgets")).toBe(true);
54
+ });
55
+
56
+ test("creates every entity's backing table when a feature registers more than one", async () => {
57
+ const twoEntityStack = await setupTestStack({ features: [twoEntityFeature] });
58
+ try {
59
+ expect(await tableExistsInInformationSchema(twoEntityStack.db, "widgets")).toBe(true);
60
+ expect(await tableExistsInInformationSchema(twoEntityStack.db, "gadgets")).toBe(true);
61
+ } finally {
62
+ await twoEntityStack.cleanup();
63
+ }
64
+ });
65
+ });
@@ -4,7 +4,7 @@
4
4
  // context slot and isn't guaranteed by this test passing.
5
5
 
6
6
  import { afterEach, describe, expect, test } from "bun:test";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { defineFeature } from "../../engine";
9
9
  import { InternalError, writeFailure } from "../../errors";
10
10
  import { waitFor } from "../../testing";
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { defineFeature } from "../../engine";
4
4
  import { resolveObservabilityWiring } from "../../observability/metrics-wiring";
5
5
  import { setupTestStack, type TestStack } from "../test-stack";