@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.
- package/package.json +4 -4
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/field-access.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/transition-guard.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
- package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write.integration.test.ts +1 -1
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +133 -0
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/__tests__/sse-broker.test.ts +144 -1
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
- package/src/api/auth-routes.ts +1 -1
- package/src/api/redis-sse-broker.ts +57 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +69 -12
- package/src/changes.json +63 -0
- package/src/compliance/override-schema.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +1 -1
- package/src/crypto/kms-adapter.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/queries/event-consumer.ts +155 -9
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +35 -1
- package/src/engine/__tests__/build-app-schema.test.ts +1 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
- package/src/engine/__tests__/engine.test.ts +1 -1
- package/src/engine/__tests__/event-helpers.test.ts +1 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
- package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +1 -1
- package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/registry.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +1 -1
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
- package/src/engine/boot-validator/screens.ts +83 -0
- package/src/engine/define-handler.ts +2 -1
- package/src/engine/entity-handlers.ts +2 -1
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
- package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
- package/src/engine/feature-builder-state.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +2 -1
- package/src/engine/feature-entity-handlers.ts +2 -1
- package/src/engine/schema-builder.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/env/__tests__/compose-env-schema.test.ts +1 -1
- package/src/env/__tests__/dry-run.test.ts +1 -1
- package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
- package/src/env/_zod-introspect.ts +1 -1
- package/src/env/dry-run.ts +1 -1
- package/src/env/index.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
- package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
- package/src/jobs/job-runner.ts +61 -6
- package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +1 -1
- package/src/observability/metrics-wiring.ts +1 -1
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
- package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
- package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/dispatch-batch.ts +49 -19
- package/src/pipeline/dispatch-stream.ts +12 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +212 -65
- package/src/pipeline/event-dispatcher.ts +253 -49
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +120 -0
- package/src/pipeline/system-hooks.ts +226 -64
- package/src/pipeline/write-origin.ts +31 -10
- package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
- package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
- package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
- package/src/search/derive-search-adapter-config.ts +27 -0
- package/src/search/index.ts +1 -0
- package/src/search/meilisearch-adapter.ts +96 -14
- package/src/secrets/__tests__/contains-secret.test.ts +1 -1
- package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/e2e-generator.ts +1 -1
|
@@ -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
|
+
}
|
package/src/search/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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" })
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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";
|
|
@@ -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
|
|
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
|
|
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";
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -470,7 +470,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
470
470
|
|
|
471
471
|
// Pre-register consumer state rows so tests can call runOnce() directly
|
|
472
472
|
// without a preceding explicit start(). Timer fires at pollIntervalMs=50
|
|
473
|
-
// but
|
|
473
|
+
// but each consumer runs at most one turn at a time — tests that drain via
|
|
474
474
|
// runOnce() remain deterministic. Tests that specifically exercise the
|
|
475
475
|
// timer loop call start() again (idempotent) after setup.
|
|
476
476
|
if (eventDispatcher) await eventDispatcher.ensureRegistered();
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// "not supported yet" — wir füllen nach, wenn ein echter Caller es braucht.
|
|
25
25
|
// Fake-komplexe-Werte würden nur False-Green-Tests produzieren.
|
|
26
26
|
|
|
27
|
-
import type
|
|
27
|
+
import type * as z from "zod";
|
|
28
28
|
import {
|
|
29
29
|
type EntityDefinition,
|
|
30
30
|
type EntityEditScreenDefinition,
|