@cosmicdrift/kumiko-framework 0.165.1 → 0.165.3

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 (45) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +7 -3
  4. package/src/api/__tests__/sse-broker.test.ts +27 -18
  5. package/src/api/routes.ts +3 -3
  6. package/src/api/sse-broker.ts +12 -13
  7. package/src/bun-db/query.ts +2 -2
  8. package/src/crypto/index.ts +1 -0
  9. package/src/crypto/subject-resolver.ts +15 -0
  10. package/src/db/__tests__/decimal-field.test.ts +3 -3
  11. package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
  12. package/src/db/__tests__/migrate-runner.test.ts +19 -1
  13. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -4
  15. package/src/db/collect-table-metas.ts +3 -3
  16. package/src/db/entity-table-meta.ts +49 -32
  17. package/src/db/index.ts +5 -1
  18. package/src/db/migrate-runner.ts +18 -11
  19. package/src/db/table-builder.ts +2 -2
  20. package/src/db/tenant-db.ts +1 -1
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +24 -6
  22. package/src/engine/__tests__/store-table.test.ts +8 -11
  23. package/src/engine/boot-validator/pii-retention.ts +18 -8
  24. package/src/engine/constants.ts +0 -4
  25. package/src/engine/feature-ast/extractors/round5.ts +1 -1
  26. package/src/engine/feature-changelog.ts +93 -0
  27. package/src/engine/feature-manifest.ts +4 -0
  28. package/src/engine/feature-ui-extensions.ts +1 -1
  29. package/src/engine/index.ts +10 -0
  30. package/src/engine/registry-state.ts +2 -2
  31. package/src/engine/validate-projection-allowlist.ts +1 -1
  32. package/src/jobs/__tests__/scheduler-id.test.ts +18 -0
  33. package/src/jobs/index.ts +1 -1
  34. package/src/jobs/job-runner.ts +31 -1
  35. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
  36. package/src/migrations/projection-table-index.ts +1 -1
  37. package/src/pipeline/__tests__/dispatcher.test.ts +61 -0
  38. package/src/pipeline/dispatch-stream.ts +22 -12
  39. package/src/pipeline/system-hooks.ts +54 -7
  40. package/src/schema-cli.ts +2 -3
  41. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +164 -0
  42. package/src/search/index.ts +1 -0
  43. package/src/search/purge-subject.ts +135 -0
  44. package/src/testing/__tests__/wait-for.test.ts +8 -4
  45. package/src/testing/wait-for.ts +10 -5
@@ -53,19 +53,25 @@ async function* executeStreamInner(
53
53
  throw validationErrorFromZod(parsed.error);
54
54
  }
55
55
 
56
- // Mid-stream access revocation must also cut *idle* SSE streams (heartbeat
57
- // only no chunk). A boolean flag read only after the next chunk would
58
- // leave revoked sessions open indefinitely (fw#1563). Race each pull
59
- // against an invalidated Deferred instead.
56
+ // Idle (heartbeat-only) streams must also cut on access revoke — race each
57
+ // pull against an invalidated Deferred instead of a post-chunk boolean.
60
58
  let resolveInvalidated: (() => void) | undefined;
61
59
  const invalidated = new Promise<void>((resolve) => {
62
60
  resolveInvalidated = resolve;
63
61
  });
64
- const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
65
- resolveInvalidated?.();
66
- });
62
+ const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation?.(
63
+ user.id,
64
+ () => {
65
+ resolveInvalidated?.();
66
+ },
67
+ );
67
68
 
68
69
  let iterator: AsyncIterator<unknown> | undefined;
70
+ // When access is revoked mid-pull, `iterator.next()` is still in flight.
71
+ // Awaiting `iterator.return()` in that state deadlocks async generators in
72
+ // Bun (overlapping next+return). Track abandonment so finally skips the
73
+ // await; close is fire-and-forget instead (#1563).
74
+ let abandonedForInvalidation = false;
69
75
  try {
70
76
  const handlerContext = buildHandlerContext(ctx, type, user);
71
77
  const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
@@ -78,6 +84,9 @@ async function* executeStreamInner(
78
84
  invalidated.then(() => ({ kind: "invalidated" as const })),
79
85
  ]);
80
86
  if (outcome.kind === "invalidated") {
87
+ abandonedForInvalidation = true;
88
+ void nextPull.catch(() => {});
89
+ void iterator.return?.(undefined)?.then(undefined, () => {});
81
90
  throw new AccessDeniedError({
82
91
  message: `access revoked mid-stream for ${type}`,
83
92
  details: { handler: type },
@@ -90,11 +99,12 @@ async function* executeStreamInner(
90
99
  }
91
100
  } finally {
92
101
  unsubscribeAccessInvalidation?.();
93
- // Consumer break / access revoke / throw — always close the handler
94
- // generator so its finally (cleanup) runs (for-await would do this).
95
- // Do NOT swallow return() errors — close-time cleanup failures must
96
- // surface to runStreamInstrumented (#1543).
97
- if (iterator !== undefined) {
102
+ // Consumer break / throw — always close the handler generator so its
103
+ // finally (cleanup) runs (for-await would do this). Do NOT swallow
104
+ // return() errors — close-time cleanup failures must surface to
105
+ // runStreamInstrumented (#1543). Skip the await after access-revoke
106
+ // abandonment (overlapping next+return deadlocks — see above).
107
+ if (iterator !== undefined && !abandonedForInvalidation) {
98
108
  await iterator.return?.(undefined);
99
109
  }
100
110
  }
@@ -1,4 +1,11 @@
1
1
  import type { SseBroker } from "../api/sse-broker";
2
+ import {
3
+ collectSearchableSubjectFields,
4
+ configuredPiiSubjectKms,
5
+ decryptPiiFieldValues,
6
+ isPiiCiphertext,
7
+ PII_ERASED_SENTINEL,
8
+ } from "../crypto";
2
9
  import type { DbRow } from "../db/connection";
3
10
  import { tenantChannel } from "../engine/constants";
4
11
  import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
@@ -53,10 +60,9 @@ export function createSearchEventConsumer(
53
60
  const verb = event.type.split(".").pop();
54
61
  const tenantId = event.tenantId;
55
62
 
56
- // skip: delete takes an early-return after removing the index entry —
57
- // the "reconstruct state" path below only makes sense for created/
58
- // updated/restored, which carry field data in the payload.
59
- if (verb === "deleted") {
63
+ // skip: delete/forgotten remove the index entry — reconstruct only
64
+ // makes sense for created/updated/restored (field data in payload).
65
+ if (verb === "deleted" || verb === "forgotten") {
60
66
  await searchAdapter.remove(tenantId, entityName, event.aggregateId);
61
67
  return;
62
68
  }
@@ -68,7 +74,13 @@ export function createSearchEventConsumer(
68
74
  return;
69
75
  }
70
76
 
71
- const state = reconstructStateForSearch(event.payload, verb);
77
+ let state = reconstructStateForSearch(event.payload, verb);
78
+ state = await decryptSearchableSubjectFields(entityName, state, registry);
79
+ // skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
80
+ if (hasErasedSearchableSubjectField(entityName, state, registry)) {
81
+ await searchAdapter.remove(tenantId, entityName, event.aggregateId);
82
+ return;
83
+ }
72
84
  const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
73
85
  if (!doc) {
74
86
  // skip: entity isn't searchable (no searchable fields declared)
@@ -79,6 +91,41 @@ export function createSearchEventConsumer(
79
91
  };
80
92
  }
81
93
 
94
+ // #1610 — subject-annotated searchable fields are ciphertext in the event
95
+ // payload; decrypt into the derived index only. No KMS → omit ciphertext
96
+ // values rather than indexing blobs.
97
+ async function decryptSearchableSubjectFields(
98
+ entityName: string,
99
+ state: Record<string, unknown>,
100
+ registry: Registry,
101
+ ): Promise<Record<string, unknown>> {
102
+ const entity = registry.getEntity(entityName);
103
+ if (!entity) return state;
104
+ const fields = collectSearchableSubjectFields(entity);
105
+ if (fields.length === 0) return state;
106
+ const kms = configuredPiiSubjectKms();
107
+ if (!kms) {
108
+ const out = { ...state };
109
+ for (const name of fields) {
110
+ if (isPiiCiphertext(out[name])) delete out[name];
111
+ }
112
+ return out;
113
+ }
114
+ return decryptPiiFieldValues(state, fields, kms, {
115
+ requestId: "system:consumer:search",
116
+ });
117
+ }
118
+
119
+ function hasErasedSearchableSubjectField(
120
+ entityName: string,
121
+ state: Record<string, unknown>,
122
+ registry: Registry,
123
+ ): boolean {
124
+ const entity = registry.getEntity(entityName);
125
+ if (!entity) return false;
126
+ return collectSearchableSubjectFields(entity).some((name) => state[name] === PII_ERASED_SENTINEL);
127
+ }
128
+
82
129
  // Rebuild the entity-state a search index needs from the event-payload alone.
83
130
  // Three shapes to handle — see event-store-executor.ts for the emitter side.
84
131
  function reconstructStateForSearch(
@@ -369,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
369
416
  // poison would otherwise permanently stop access-invalidation for
370
417
  // every user behind one bad row).
371
418
  if (typeof userId !== "string" || userId.length === 0) return;
372
- sseBroker.publishAccessInvalidation(userId);
419
+ sseBroker.publishAccessInvalidation?.(userId);
373
420
  }
374
421
 
375
422
  if (
@@ -380,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
380
427
  // skip: previous snapshot missing/malformed userId — same fail-open
381
428
  // reasoning as above.
382
429
  if (userId === undefined) return;
383
- sseBroker.publishAccessInvalidation(userId);
430
+ sseBroker.publishAccessInvalidation?.(userId);
384
431
  }
385
432
  },
386
433
  };
package/src/schema-cli.ts CHANGED
@@ -273,11 +273,10 @@ export async function runSchemaCli(
273
273
 
274
274
  // 3. Migration-content drift — replay the committed *.sql files and
275
275
  // diff the reconstructed schema against .snapshot.json.
276
- const committedSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
277
- if (existsSync(migrationsDir) && committedSnapshot !== null) {
276
+ if (existsSync(migrationsDir) && prevSnapshot !== null) {
278
277
  try {
279
278
  const replayed = replayMigrationsDir(migrationsDir);
280
- const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
279
+ const mismatches = diffReplayAgainstSnapshot(replayed, prevSnapshot);
281
280
  if (mismatches.length === 0) {
282
281
  out.log(" ✓ migrations: table/column names match .snapshot.json");
283
282
  } else {
@@ -0,0 +1,164 @@
1
+ // fw#1610 — subject-annotated searchable fields: ciphertext in events,
2
+ // plaintext in derived search index, purged on subject erase.
3
+
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import {
6
+ configurePiiSubjectKms,
7
+ InMemoryKmsAdapter,
8
+ isPiiCiphertext,
9
+ resetPiiSubjectKmsForTests,
10
+ subjectIdToKey,
11
+ } from "../../crypto";
12
+ import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
13
+ import { createEntity, createTextField, defineFeature } from "../../engine";
14
+ import { createEventsTable } from "../../event-store";
15
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
+ import { purgeSearchDocumentsForSubject } from "../purge-subject";
17
+
18
+ const contactEntity = createEntity({
19
+ table: "read_search_pii_contacts",
20
+ fields: {
21
+ // pii: true → subject = entity id (self). searchable via derived index.
22
+ label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
23
+ note: createTextField({ required: true, maxLength: 100, searchable: true }),
24
+ },
25
+ });
26
+
27
+ const contactTable = buildEntityTable("contact", contactEntity);
28
+
29
+ const contactFeature = defineFeature("search-pii-probe", (r) => {
30
+ r.entity("contact", contactEntity);
31
+ });
32
+
33
+ let stack: TestStack;
34
+ let kms: InMemoryKmsAdapter;
35
+ const admin = TestUsers.admin;
36
+
37
+ beforeAll(async () => {
38
+ stack = await setupTestStack({ features: [contactFeature] });
39
+ await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
40
+ await createEventsTable(stack.db);
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await stack.cleanup();
45
+ });
46
+
47
+ beforeEach(() => {
48
+ kms = new InMemoryKmsAdapter();
49
+ configurePiiSubjectKms(kms);
50
+ });
51
+
52
+ afterEach(() => {
53
+ resetPiiSubjectKmsForTests();
54
+ });
55
+
56
+ function executor() {
57
+ return createEventStoreExecutor(contactTable, contactEntity, {
58
+ entityName: "contact",
59
+ searchAdapter: stack.search,
60
+ });
61
+ }
62
+
63
+ function tenantDb() {
64
+ return createTenantDb(stack.db, admin.tenantId, "system");
65
+ }
66
+
67
+ describe("searchable PII derived index (#1610)", () => {
68
+ test("create indexes plaintext; event payload stays ciphertext; erase purges search", async () => {
69
+ const plain = "UniqueSearchPiiLabel1610";
70
+ const created = await executor().create(
71
+ { label: plain, note: "public-note" },
72
+ admin,
73
+ tenantDb(),
74
+ );
75
+ if (!created.isSuccess) throw new Error("create failed");
76
+ const id = String(created.data.id);
77
+
78
+ const events = await asRawClient(stack.db).unsafe(
79
+ `SELECT payload FROM kumiko_events WHERE aggregate_id = $1 AND type = 'contact.created' LIMIT 1`,
80
+ [id],
81
+ );
82
+ const payload = (events as { payload: Record<string, unknown> }[])[0]?.payload;
83
+ expect(isPiiCiphertext(payload?.["label"])).toBe(true);
84
+ expect(payload?.["label"]).not.toBe(plain);
85
+
86
+ await stack.eventDispatcher?.runOnce();
87
+
88
+ const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
89
+ expect(hits.some((h) => String(h.entityId) === id)).toBe(true);
90
+
91
+ // pii: true → subject key is the entity id itself.
92
+ const subject = { kind: "user" as const, userId: id };
93
+ await kms.eraseKey(subject);
94
+ await purgeSearchDocumentsForSubject(
95
+ stack.db,
96
+ stack.registry.features,
97
+ stack.search,
98
+ subjectIdToKey(subject),
99
+ subject,
100
+ );
101
+
102
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
103
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
104
+ });
105
+
106
+ test("consumer treats erased decrypt as remove (no sentinel index)", async () => {
107
+ const plain = "SentinelRebuildLabel1610";
108
+ const created = await executor().create({ label: plain, note: "x" }, admin, tenantDb());
109
+ if (!created.isSuccess) throw new Error("create failed");
110
+ const id = String(created.data.id);
111
+
112
+ await stack.eventDispatcher?.runOnce();
113
+ expect(
114
+ (await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
115
+ (h) => String(h.entityId) === id,
116
+ ),
117
+ ).toBe(true);
118
+
119
+ await kms.eraseKey({ kind: "user", userId: id });
120
+ // Force re-index path by updating a non-PII field — consumer decrypts
121
+ // label → [[erased]] → remove.
122
+ const updated = await executor().update(
123
+ { id: created.data.id, changes: { note: "y" } },
124
+ admin,
125
+ tenantDb(),
126
+ { skipOptimisticLock: true },
127
+ );
128
+ if (!updated.isSuccess) throw new Error("update failed");
129
+ await stack.eventDispatcher?.runOnce();
130
+
131
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
132
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
133
+ });
134
+ test("purge finds rows after anonymize rewrote ciphertext (#1610 bugbot)", async () => {
135
+ const plain = "AnonymizedStillPurge1610";
136
+ const created = await executor().create({ label: plain, note: "n" }, admin, tenantDb());
137
+ if (!created.isSuccess) throw new Error("create failed");
138
+ const id = String(created.data.id);
139
+ await stack.eventDispatcher?.runOnce();
140
+ expect(
141
+ (await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
142
+ (h) => String(h.entityId) === id,
143
+ ),
144
+ ).toBe(true);
145
+
146
+ // Simulate forget-cleanup anonymize: overwrite searchable PII with plaintext.
147
+ await asRawClient(stack.db).unsafe(
148
+ `UPDATE read_search_pii_contacts SET label = $1 WHERE id = $2`,
149
+ ["[[erased]]", id],
150
+ );
151
+
152
+ const subject = { kind: "user" as const, userId: id };
153
+ await purgeSearchDocumentsForSubject(
154
+ stack.db,
155
+ stack.registry.features,
156
+ stack.search,
157
+ subjectIdToKey(subject),
158
+ subject,
159
+ );
160
+
161
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
162
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
163
+ });
164
+ });
@@ -3,6 +3,7 @@
3
3
  // von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
4
4
  // Client-Code nicht mit rein.
5
5
  export { createInMemorySearchAdapter } from "./in-memory-adapter";
6
+ export { purgeSearchDocumentsForSubject } from "./purge-subject";
6
7
  export type {
7
8
  ReindexEntityFailure,
8
9
  ReindexEntityOptions,
@@ -0,0 +1,135 @@
1
+ // Purge derived search documents for an erased PII subject (#1610).
2
+ //
3
+ // After kms.eraseKey the projection/event ciphertext is unreadable, but Meili
4
+ // still holds the plaintext that createSearchEventConsumer decrypted into the
5
+ // index. Discovery is dual-path:
6
+ // 1. Ownership: pii self-id / userOwned.ownerField / tenantOwned.tenantId
7
+ // (survives anonymize hooks that overwrite ciphertext with plaintext).
8
+ // 2. Ciphertext LIKE prefix (same as nullBlindIndexesForSubject) for rows
9
+ // that still carry the subject key in encrypted columns.
10
+
11
+ import type { SubjectId } from "../crypto/kms-adapter";
12
+ import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
13
+ import type { DbRunner } from "../db/connection";
14
+ import { resolveTableName } from "../db/entity-table-meta";
15
+ import { executeRawQuery } from "../db/queries/raw-sql";
16
+ import type { FeatureDefinition } from "../engine/types";
17
+ import type { EntityDefinition } from "../engine/types/fields";
18
+ import type { EntityId, TenantId } from "../engine/types/identifiers";
19
+ import { toSnakeCase } from "../utils/case";
20
+ import type { SearchAdapter } from "./types";
21
+
22
+ function quoteIdent(name: string): string {
23
+ return `"${name.replace(/"/g, '""')}"`;
24
+ }
25
+
26
+ function escapeLikePattern(value: string): string {
27
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
28
+ }
29
+
30
+ /** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
31
+ function ownershipPredicates(
32
+ entity: EntityDefinition,
33
+ searchableFields: readonly string[],
34
+ subject: SubjectId,
35
+ nextParam: () => number,
36
+ ): { sql: string; params: unknown[] } | null {
37
+ const parts: string[] = [];
38
+ const params: unknown[] = [];
39
+ let selfIdN: number | undefined;
40
+ let tenantIdN: number | undefined;
41
+ const ownerFieldN = new Map<string, number>();
42
+
43
+ for (const fieldName of searchableFields) {
44
+ const field = entity.fields[fieldName];
45
+ if (!field) continue;
46
+ if (subject.kind === "user") {
47
+ if ("userOwned" in field && field.userOwned !== undefined) {
48
+ const col = toSnakeCase(field.userOwned.ownerField);
49
+ let n = ownerFieldN.get(col);
50
+ if (n === undefined) {
51
+ n = nextParam();
52
+ ownerFieldN.set(col, n);
53
+ params.push(subject.userId);
54
+ parts.push(`${quoteIdent(col)} = $${n}`);
55
+ }
56
+ } else if ("pii" in field && field.pii === true && selfIdN === undefined) {
57
+ selfIdN = nextParam();
58
+ params.push(subject.userId);
59
+ parts.push(`${quoteIdent("id")} = $${selfIdN}`);
60
+ }
61
+ } else if ("tenantOwned" in field && field.tenantOwned === true && tenantIdN === undefined) {
62
+ tenantIdN = nextParam();
63
+ params.push(subject.tenantId);
64
+ parts.push(`${quoteIdent("tenant_id")} = $${tenantIdN}`);
65
+ }
66
+ }
67
+ if (parts.length === 0) return null;
68
+ return { sql: parts.join(" OR "), params };
69
+ }
70
+
71
+ export async function purgeSearchDocumentsForSubject(
72
+ db: DbRunner,
73
+ features: ReadonlyMap<string, FeatureDefinition>,
74
+ search: SearchAdapter,
75
+ subjectKey: string,
76
+ /** When set, also match rows by ownership — needed after anonymize rewrites ciphertext. */
77
+ subject?: SubjectId,
78
+ ): Promise<void> {
79
+ const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
80
+ const byTenant = new Map<string, { entityType: string; entityId: EntityId }[]>();
81
+ const seen = new Set<string>();
82
+
83
+ for (const feature of features.values()) {
84
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
85
+ const fields = collectSearchableSubjectFields(entity);
86
+ if (fields.length === 0) continue;
87
+ const tableName = resolveTableName(entityName, entity, undefined);
88
+
89
+ let paramIdx = 0;
90
+ const nextParam = () => ++paramIdx;
91
+ const params: unknown[] = [];
92
+ const orParts: string[] = [];
93
+
94
+ const likeN = nextParam();
95
+ params.push(likePattern);
96
+ orParts.push(
97
+ `(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
98
+ );
99
+
100
+ if (subject) {
101
+ const owned = ownershipPredicates(entity, fields, subject, nextParam);
102
+ if (owned) {
103
+ params.push(...owned.params);
104
+ orParts.push(`(${owned.sql})`);
105
+ }
106
+ }
107
+
108
+ const rows = await executeRawQuery<{ id: string; tenant_id: string }>(
109
+ db,
110
+ `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${orParts.join(" OR ")}`,
111
+ params,
112
+ );
113
+ for (const row of rows) {
114
+ const key = `${row.tenant_id}:${entityName}:${row.id}`;
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ const list = byTenant.get(row.tenant_id) ?? [];
118
+ list.push({ entityType: entityName, entityId: row.id as EntityId });
119
+ byTenant.set(row.tenant_id, list);
120
+ }
121
+ }
122
+ }
123
+
124
+ for (const [tenantId, items] of byTenant) {
125
+ if (items.length === 0) continue;
126
+ const tid = tenantId as TenantId;
127
+ if (search.removeBatch) {
128
+ await search.removeBatch(tid, items);
129
+ } else {
130
+ for (const item of items) {
131
+ await search.remove(tid, item.entityType, item.entityId);
132
+ }
133
+ }
134
+ }
135
+ }
@@ -2,15 +2,18 @@ import { describe, expect, test } from "bun:test";
2
2
  import { waitFor } from "../wait-for";
3
3
 
4
4
  describe("waitFor", () => {
5
- test("returns immediately once fn succeeds on the first attempt", async () => {
5
+ test("calls fn exactly once when it passes on the first attempt (no prior sleep)", async () => {
6
6
  let calls = 0;
7
+ const started = Date.now();
7
8
  await waitFor(
8
9
  () => {
9
10
  calls++;
10
11
  },
11
- { delays: [1, 1, 1] },
12
+ { delays: [200, 200, 200] },
12
13
  );
13
14
  expect(calls).toBe(1);
15
+ // try-first: must not burn the first delay when the condition already holds
16
+ expect(Date.now() - started).toBeLessThan(100);
14
17
  });
15
18
 
16
19
  test("retries on failure and succeeds once fn passes", async () => {
@@ -35,8 +38,9 @@ describe("waitFor", () => {
35
38
  },
36
39
  { delays: [1, 1] },
37
40
  ),
38
- ).rejects.toThrow("fail-2");
39
- expect(calls).toBe(2);
41
+ ).rejects.toThrow("fail-3");
42
+ // N delays → N+1 attempts (final try after the last backoff)
43
+ expect(calls).toBe(3);
40
44
  });
41
45
 
42
46
  test("throws a descriptive error for an empty delay schedule", async () => {
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Polls a condition with escalating timeouts.
3
3
  *
4
- * Default schedule: 250ms → 1s → 3s (3 attempts).
5
- * Returns immediately on success. Throws the last assertion error if all attempts fail.
4
+ * Default schedule: 250ms → 1s → 3s between attempts. Tries first (already-true
5
+ * returns immediately), then sleeps `delays[i]` after each failure before the
6
+ * next try — so N delays yield N+1 attempts and the full backoff budget.
7
+ * Throws the last assertion error if all attempts fail.
6
8
  *
7
9
  * Usage:
8
10
  * await waitFor(() => {
@@ -19,15 +21,18 @@ export async function waitFor(
19
21
  }
20
22
  let lastError: unknown;
21
23
 
22
- for (let i = 0; i < delays.length; i++) {
23
- await new Promise((r) => setTimeout(r, delays[i]));
24
+ for (let i = 0; ; i++) {
24
25
  try {
25
26
  await fn();
26
- // skip: retry attempt succeeded, no further polling needed
27
+ // skip: condition already true no further polling
27
28
  return;
28
29
  } catch (err) {
29
30
  lastError = err;
30
31
  }
32
+ if (i >= delays.length) {
33
+ break;
34
+ }
35
+ await new Promise((r) => setTimeout(r, delays[i]));
31
36
  }
32
37
 
33
38
  throw lastError;