@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.
@@ -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
- await waitFor(() => expect(wrapperCalls).toBeGreaterThanOrEqual(1), {
941
- delays: [2000, 3000, 5000],
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
- expect(log.length).toBe(wrapperCalls * tenants.length);
959
- const seenTenants = log.map((e) => e.tenantId).sort();
960
- const expectedTenants = Array.from({ length: wrapperCalls }, () => tenants)
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(() => expect(wrapperCalls).toBeGreaterThanOrEqual(2), {
1133
- delays: [1500, 2000, 3000],
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).toBe(wrapperCalls * tenants.length);
1141
- const seenTenants = log.map((e) => e.tenantId).sort();
1142
- const expectedTenants = Array.from({ length: wrapperCalls }, () => tenants)
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
  });
@@ -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
- fn: () => void | Promise<void>,
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
- // skip: condition already true — no further polling
28
- return;
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
- throw lastError;
45
+ if (lastError !== undefined) {
46
+ throw lastError;
47
+ }
48
+ throw new Error("waitFor: condition never became true within the delay schedule");
39
49
  }