@cosmicdrift/kumiko-framework 0.230.0 → 0.232.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 +3 -3
- package/src/bun-db/query.ts +37 -36
- package/src/compliance/__tests__/sub-processors.test.ts +12 -12
- package/src/compliance/sub-processors.ts +11 -11
- package/src/db/__tests__/blind-index.integration.test.ts +32 -0
- package/src/db/blind-index-cleanup.ts +6 -0
- package/src/db/entity-table-meta.ts +23 -23
- package/src/engine/__tests__/build-app-schema.test.ts +42 -0
- package/src/engine/__tests__/define-workflow.test.ts +23 -1
- package/src/engine/__tests__/steps-workflow.test.ts +160 -5
- package/src/engine/define-workflow.ts +48 -11
- package/src/engine/feature-ui-extensions.ts +6 -3
- package/src/engine/index.ts +2 -0
- package/src/engine/pipeline.ts +34 -7
- package/src/engine/steps/_event-match.ts +98 -0
- package/src/engine/steps/wait-for-event.ts +25 -6
- package/src/files/__tests__/file-handle.test.ts +26 -0
- package/src/files/file-handle.ts +1 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +40 -18
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +41 -1
- package/src/search/purge-subject.ts +5 -0
- package/src/testing/__tests__/wait-for.test.ts +41 -0
- package/src/testing/wait-for.ts +16 -6
|
@@ -925,6 +925,9 @@ describe("perTenant across multiple runner instances", () => {
|
|
|
925
925
|
// A post-close 'error' here is otherwise unhandled and bun:test
|
|
926
926
|
// attributes it to whichever test runs next (fw#1805).
|
|
927
927
|
rawQueue.on("error", () => {});
|
|
928
|
+
// Snapshot of completed wrapper runs — declared outside try so the
|
|
929
|
+
// post-finally fan-out check can read it.
|
|
930
|
+
let drainedCalls = 0;
|
|
928
931
|
try {
|
|
929
932
|
// Both instances call start() → both call upsertJobScheduler() with
|
|
930
933
|
// the SAME schedulerId against the SAME Redis. If that didn't dedup,
|
|
@@ -937,27 +940,38 @@ describe("perTenant across multiple runner instances", () => {
|
|
|
937
940
|
// called upsertJobScheduler() against it. Two entries here means two
|
|
938
941
|
// independent repeat timers, which would double-fire every tick.
|
|
939
942
|
expect(await rawQueue.getJobSchedulersCount()).toBe(1);
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
+
// Wait for the asserted drain state while workers are still up —
|
|
944
|
+
// stop() tears down consumers, so a post-finally sleep/assert races
|
|
945
|
+
// on unprocessed children (fw#2556: expected 4, got 2). Snapshot
|
|
946
|
+
// drainedCalls inside the predicate; do not assert a post-stop
|
|
947
|
+
// log-length ceiling — in-flight children (and sequential multi-
|
|
948
|
+
// runner stop) can still append after the snapshot.
|
|
949
|
+
await waitFor(
|
|
950
|
+
() => {
|
|
951
|
+
expect(wrapperCalls).toBeGreaterThanOrEqual(1);
|
|
952
|
+
expect(log.length).toBe(wrapperCalls * tenants.length);
|
|
953
|
+
drainedCalls = wrapperCalls;
|
|
954
|
+
},
|
|
955
|
+
{ delays: [2000, 3000, 5000] },
|
|
956
|
+
);
|
|
943
957
|
} finally {
|
|
944
|
-
// Stop immediately after catching the first tick so no further ticks
|
|
945
|
-
// can land mid-assertion — the invariant below still holds however
|
|
946
|
-
// many ticks slipped in before stop() took effect.
|
|
947
958
|
await runnerA.stop();
|
|
948
959
|
await runnerB.stop();
|
|
949
960
|
await rawQueue.close();
|
|
950
961
|
}
|
|
951
|
-
await sleep(300); // let any in-flight children land
|
|
952
962
|
|
|
953
963
|
// Secondary sanity check only — this ratio holds whether the wrapper
|
|
954
964
|
// fired once or twice per tick (wrapperCalls scales with it either
|
|
955
965
|
// way), so it does not by itself prove the once-per-tick invariant.
|
|
956
966
|
// It only confirms each wrapper run that did happen fanned out to
|
|
957
|
-
// every tenant exactly once.
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
const
|
|
967
|
+
// every tenant exactly once. Slice to the drained prefix so a late
|
|
968
|
+
// tick that lands during stop() cannot break the multiset check.
|
|
969
|
+
expect(log.length).toBeGreaterThanOrEqual(drainedCalls * tenants.length);
|
|
970
|
+
const seenTenants = log
|
|
971
|
+
.slice(0, drainedCalls * tenants.length)
|
|
972
|
+
.map((e) => e.tenantId)
|
|
973
|
+
.sort();
|
|
974
|
+
const expectedTenants = Array.from({ length: drainedCalls }, () => tenants)
|
|
961
975
|
.flat()
|
|
962
976
|
.sort();
|
|
963
977
|
expect(seenTenants).toEqual(expectedTenants);
|
|
@@ -1123,23 +1137,31 @@ describe("perTenant wrapper re-run for the same trigger", () => {
|
|
|
1123
1137
|
queueNamePrefix,
|
|
1124
1138
|
getActiveTenantIds,
|
|
1125
1139
|
});
|
|
1140
|
+
let drainedCalls = 0;
|
|
1126
1141
|
try {
|
|
1127
1142
|
await runner.start();
|
|
1128
1143
|
// Two distinct ticks get two distinct repeat-job ids (different
|
|
1129
1144
|
// millis) — each must still produce its own full batch of children;
|
|
1130
1145
|
// a wrongly cross-tick dedup would collapse the second tick's log
|
|
1131
1146
|
// entries into the first.
|
|
1132
|
-
await waitFor(
|
|
1133
|
-
|
|
1134
|
-
|
|
1147
|
+
await waitFor(
|
|
1148
|
+
() => {
|
|
1149
|
+
expect(wrapperCalls).toBeGreaterThanOrEqual(2);
|
|
1150
|
+
expect(log.length).toBe(wrapperCalls * tenants.length);
|
|
1151
|
+
drainedCalls = wrapperCalls;
|
|
1152
|
+
},
|
|
1153
|
+
{ delays: [1500, 2000, 3000] },
|
|
1154
|
+
);
|
|
1135
1155
|
} finally {
|
|
1136
1156
|
await runner.stop();
|
|
1137
1157
|
}
|
|
1138
|
-
await sleep(300); // let any in-flight children land
|
|
1139
1158
|
|
|
1140
|
-
expect(log.length).
|
|
1141
|
-
const seenTenants = log
|
|
1142
|
-
|
|
1159
|
+
expect(log.length).toBeGreaterThanOrEqual(drainedCalls * tenants.length);
|
|
1160
|
+
const seenTenants = log
|
|
1161
|
+
.slice(0, drainedCalls * tenants.length)
|
|
1162
|
+
.map((e) => e.tenantId)
|
|
1163
|
+
.sort();
|
|
1164
|
+
const expectedTenants = Array.from({ length: drainedCalls }, () => tenants)
|
|
1143
1165
|
.flat()
|
|
1144
1166
|
.sort();
|
|
1145
1167
|
expect(seenTenants).toEqual(expectedTenants);
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
subjectIdToKey,
|
|
11
11
|
} from "../../crypto";
|
|
12
12
|
import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
|
|
13
|
-
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
13
|
+
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
14
14
|
import { createEventsTable } from "../../event-store";
|
|
15
15
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
16
16
|
import { purgeSearchDocumentsForSubject } from "../purge-subject";
|
|
@@ -30,6 +30,16 @@ const contactFeature = defineFeature("search-pii-probe", (r) => {
|
|
|
30
30
|
r.entity("contact", contactEntity);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
const ghostSearchEntity = createEntity({
|
|
34
|
+
table: "read_search_pii_ghosts_missing",
|
|
35
|
+
fields: {
|
|
36
|
+
label: createTextField({ required: true, maxLength: 100, personal: "self", find: "fuzzy" }),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const ghostSearchFeature = defineFeature("search-pii-ghost", (r) => {
|
|
40
|
+
r.entity("ghost", ghostSearchEntity);
|
|
41
|
+
});
|
|
42
|
+
|
|
33
43
|
let stack: TestStack;
|
|
34
44
|
let kms: InMemoryKmsAdapter;
|
|
35
45
|
const admin = TestUsers.admin;
|
|
@@ -161,4 +171,34 @@ describe("searchable PII derived index (#1610)", () => {
|
|
|
161
171
|
const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
|
|
162
172
|
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
163
173
|
});
|
|
174
|
+
|
|
175
|
+
test("purgeSearchDocumentsForSubject skips missing projection tables (fw#2550)", async () => {
|
|
176
|
+
const plain = "MissingTableStillPurges1610";
|
|
177
|
+
const created = await executor().create({ label: plain, note: "n" }, admin, tenantDb());
|
|
178
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
179
|
+
const id = String(created.data.id);
|
|
180
|
+
await stack.eventDispatcher?.runOnce();
|
|
181
|
+
expect(
|
|
182
|
+
(await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
|
|
183
|
+
(h) => String(h.entityId) === id,
|
|
184
|
+
),
|
|
185
|
+
).toBe(true);
|
|
186
|
+
|
|
187
|
+
const subject = { kind: "user" as const, userId: id };
|
|
188
|
+
await kms.eraseKey(subject);
|
|
189
|
+
// Ghost searchable entity never got DDL — must not abort the purge.
|
|
190
|
+
const registry = createRegistry([contactFeature, ghostSearchFeature]);
|
|
191
|
+
await expect(
|
|
192
|
+
purgeSearchDocumentsForSubject(
|
|
193
|
+
stack.db,
|
|
194
|
+
registry.features,
|
|
195
|
+
stack.search,
|
|
196
|
+
subjectIdToKey(subject),
|
|
197
|
+
subject,
|
|
198
|
+
),
|
|
199
|
+
).resolves.toBeUndefined();
|
|
200
|
+
|
|
201
|
+
const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
|
|
202
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
203
|
+
});
|
|
164
204
|
});
|
|
@@ -15,6 +15,7 @@ import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
|
|
|
15
15
|
import type { DbRunner } from "../db/connection";
|
|
16
16
|
import { resolveTableName } from "../db/entity-table-meta";
|
|
17
17
|
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
18
|
+
import { tableExists } from "../db/schema-inspection";
|
|
18
19
|
import type { FeatureDefinition } from "../engine/types";
|
|
19
20
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
20
21
|
import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
@@ -139,6 +140,10 @@ export async function purgeSearchDocumentsForSubject(
|
|
|
139
140
|
const fields = collectSearchableSubjectFields(entity);
|
|
140
141
|
if (fields.length === 0) continue;
|
|
141
142
|
const tableName = resolveTableName(entityName, entity, undefined);
|
|
143
|
+
// Same post-eraseKey hazard as nullBlindIndexesForSubject (fw#2550):
|
|
144
|
+
// a mounted feature without its migration must not abort the purge
|
|
145
|
+
// (and the audit event after it).
|
|
146
|
+
if (!(await tableExists(db, tableName))) continue;
|
|
142
147
|
const predicate = buildSubjectPredicate(entity, fields, likePattern, subject);
|
|
143
148
|
const rows = await collectMatchingRowsForEntity(
|
|
144
149
|
db,
|
|
@@ -60,4 +60,45 @@ describe("waitFor", () => {
|
|
|
60
60
|
);
|
|
61
61
|
expect(calls).toBe(2);
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
test("polls a boolean predicate until it returns true", async () => {
|
|
65
|
+
let calls = 0;
|
|
66
|
+
await waitFor(
|
|
67
|
+
() => {
|
|
68
|
+
calls++;
|
|
69
|
+
return calls >= 3;
|
|
70
|
+
},
|
|
71
|
+
{ delays: [1, 1, 1] },
|
|
72
|
+
);
|
|
73
|
+
expect(calls).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws a descriptive error when a predicate stays false for the whole schedule", async () => {
|
|
77
|
+
let calls = 0;
|
|
78
|
+
await expect(
|
|
79
|
+
waitFor(
|
|
80
|
+
() => {
|
|
81
|
+
calls++;
|
|
82
|
+
return false;
|
|
83
|
+
},
|
|
84
|
+
{ delays: [1, 1] },
|
|
85
|
+
),
|
|
86
|
+
).rejects.toThrow("waitFor: condition never became true within the delay schedule");
|
|
87
|
+
expect(calls).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns immediately for a block-style fn that returns undefined without throwing", async () => {
|
|
91
|
+
let calls = 0;
|
|
92
|
+
const events = [1];
|
|
93
|
+
const started = Date.now();
|
|
94
|
+
await waitFor(
|
|
95
|
+
() => {
|
|
96
|
+
calls++;
|
|
97
|
+
expect(events).toHaveLength(1);
|
|
98
|
+
},
|
|
99
|
+
{ delays: [2000] },
|
|
100
|
+
);
|
|
101
|
+
expect(calls).toBe(1);
|
|
102
|
+
expect(Date.now() - started).toBeLessThan(500);
|
|
103
|
+
});
|
|
63
104
|
});
|
package/src/testing/wait-for.ts
CHANGED
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
* next try — so N delays yield N+1 attempts and the full backoff budget.
|
|
7
7
|
* Throws the last assertion error if all attempts fail.
|
|
8
8
|
*
|
|
9
|
-
* Usage:
|
|
9
|
+
* Usage (assertion form):
|
|
10
10
|
* await waitFor(() => {
|
|
11
11
|
* expect(events).toHaveLength(1);
|
|
12
12
|
* });
|
|
13
|
+
*
|
|
14
|
+
* Usage (predicate form — a literal `false` return means "not yet", anything
|
|
15
|
+
* else, including `undefined`, counts as success):
|
|
16
|
+
* await waitFor(() => events.length >= 4);
|
|
13
17
|
*/
|
|
14
18
|
export async function waitFor(
|
|
15
|
-
|
|
19
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: void must stay in the union so the existing expect(...) block form (implicit void return) still type-checks
|
|
20
|
+
fn: () => void | boolean | Promise<void | boolean>,
|
|
16
21
|
options?: { delays?: number[] },
|
|
17
22
|
): Promise<void> {
|
|
18
23
|
const delays = options?.delays ?? [250, 1000, 3000];
|
|
@@ -23,9 +28,11 @@ export async function waitFor(
|
|
|
23
28
|
|
|
24
29
|
for (let i = 0; ; i++) {
|
|
25
30
|
try {
|
|
26
|
-
await fn();
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const result = await fn();
|
|
32
|
+
if (result !== false) {
|
|
33
|
+
// skip: condition already true — no further polling
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
29
36
|
} catch (err) {
|
|
30
37
|
lastError = err;
|
|
31
38
|
}
|
|
@@ -35,5 +42,8 @@ export async function waitFor(
|
|
|
35
42
|
await new Promise((r) => setTimeout(r, delays[i]));
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
|
|
45
|
+
if (lastError !== undefined) {
|
|
46
|
+
throw lastError;
|
|
47
|
+
}
|
|
48
|
+
throw new Error("waitFor: condition never became true within the delay schedule");
|
|
39
49
|
}
|