@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.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +7 -3
- package/src/api/__tests__/sse-broker.test.ts +27 -18
- package/src/api/routes.ts +3 -3
- package/src/api/sse-broker.ts +12 -13
- package/src/bun-db/query.ts +2 -2
- package/src/crypto/index.ts +1 -0
- package/src/crypto/subject-resolver.ts +15 -0
- package/src/db/__tests__/decimal-field.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
- package/src/db/__tests__/migrate-runner.test.ts +19 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
- package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -4
- package/src/db/collect-table-metas.ts +3 -3
- package/src/db/entity-table-meta.ts +49 -32
- package/src/db/index.ts +5 -1
- package/src/db/migrate-runner.ts +18 -11
- package/src/db/table-builder.ts +2 -2
- package/src/db/tenant-db.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +24 -6
- package/src/engine/__tests__/store-table.test.ts +8 -11
- package/src/engine/boot-validator/pii-retention.ts +18 -8
- package/src/engine/constants.ts +0 -4
- package/src/engine/feature-ast/extractors/round5.ts +1 -1
- package/src/engine/feature-changelog.ts +93 -0
- package/src/engine/feature-manifest.ts +4 -0
- package/src/engine/feature-ui-extensions.ts +1 -1
- package/src/engine/index.ts +10 -0
- package/src/engine/registry-state.ts +2 -2
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/jobs/__tests__/scheduler-id.test.ts +18 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-runner.ts +31 -1
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
- package/src/migrations/projection-table-index.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +61 -0
- package/src/pipeline/dispatch-stream.ts +22 -12
- package/src/pipeline/system-hooks.ts +54 -7
- package/src/schema-cli.ts +2 -3
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +164 -0
- package/src/search/index.ts +1 -0
- package/src/search/purge-subject.ts +135 -0
- package/src/testing/__tests__/wait-for.test.ts +8 -4
- 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
|
-
//
|
|
57
|
-
//
|
|
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(
|
|
65
|
-
|
|
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 /
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
|
57
|
-
//
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
});
|
package/src/search/index.ts
CHANGED
|
@@ -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("
|
|
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: [
|
|
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-
|
|
39
|
-
|
|
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 () => {
|
package/src/testing/wait-for.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Polls a condition with escalating timeouts.
|
|
3
3
|
*
|
|
4
|
-
* Default schedule: 250ms → 1s → 3s
|
|
5
|
-
*
|
|
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;
|
|
23
|
-
await new Promise((r) => setTimeout(r, delays[i]));
|
|
24
|
+
for (let i = 0; ; i++) {
|
|
24
25
|
try {
|
|
25
26
|
await fn();
|
|
26
|
-
// skip:
|
|
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;
|