@cosmicdrift/kumiko-framework 0.209.0 → 0.210.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/__tests__/entity-permalink-open.integration.test.ts +6 -1
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
- package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/cursor.test.ts +26 -1
- package/src/db/__tests__/eagerload.integration.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
- package/src/db/cursor.ts +32 -0
- package/src/db/event-store-executor-read.ts +80 -9
- package/src/db/index.ts +2 -2
- package/src/db/pg-error.ts +7 -0
- package/src/db/queries/backfill-pii.ts +189 -19
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
- package/src/engine/__tests__/factories-personal.test.ts +130 -0
- package/src/engine/__tests__/field-access.test.ts +1 -23
- package/src/engine/__tests__/store-table.test.ts +4 -1
- package/src/engine/boot-validator/pii-retention.ts +22 -54
- package/src/engine/build-config-feature-schema.ts +9 -1
- package/src/engine/factories.ts +108 -30
- package/src/engine/field-access.ts +2 -13
- package/src/engine/index.ts +7 -1
- package/src/engine/types/index.ts +7 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +205 -2
- package/src/files/file-ref-entity.ts +2 -2
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
- package/src/testing/shared-entities.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.210.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
"./package.json": "./package.json"
|
|
191
191
|
},
|
|
192
192
|
"dependencies": {
|
|
193
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
193
|
+
"@cosmicdrift/kumiko-types": "0.210.0",
|
|
194
194
|
"bullmq": "^5.76.7",
|
|
195
195
|
"bun-types": "^1.3.13",
|
|
196
196
|
"hono": "^4.13.1",
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
"zod": "^4.4.3"
|
|
207
207
|
},
|
|
208
208
|
"devDependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.210.0",
|
|
210
210
|
"bun-types": "^1.3.13",
|
|
211
211
|
"pino-pretty": "^13.1.3"
|
|
212
212
|
},
|
|
@@ -26,7 +26,12 @@ import {
|
|
|
26
26
|
const noteEntity = createEntity({
|
|
27
27
|
table: "read_permalink_notes",
|
|
28
28
|
fields: {
|
|
29
|
-
title: createTextField({
|
|
29
|
+
title: createTextField({
|
|
30
|
+
required: true,
|
|
31
|
+
maxLength: 160,
|
|
32
|
+
personal: false,
|
|
33
|
+
reason: "is_business_data",
|
|
34
|
+
}),
|
|
30
35
|
},
|
|
31
36
|
});
|
|
32
37
|
|
|
@@ -24,7 +24,7 @@ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
|
24
24
|
|
|
25
25
|
const userLikeEntity = createEntity({
|
|
26
26
|
fields: {
|
|
27
|
-
email: createTextField({ required: true,
|
|
27
|
+
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
28
28
|
role: createTextField(),
|
|
29
29
|
},
|
|
30
30
|
table: "bidx_users",
|
|
@@ -26,7 +26,7 @@ const KMS_CTX: KmsContext = { requestId: "test" };
|
|
|
26
26
|
|
|
27
27
|
const userLikeEntity = createEntity({
|
|
28
28
|
fields: {
|
|
29
|
-
email: createTextField({ required: true,
|
|
29
|
+
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
30
30
|
role: createTextField(),
|
|
31
31
|
},
|
|
32
32
|
table: "pii_users",
|
|
@@ -34,7 +34,10 @@ const userLikeEntity = createEntity({
|
|
|
34
34
|
|
|
35
35
|
const commentEntity = createEntity({
|
|
36
36
|
fields: {
|
|
37
|
-
body: createTextField({
|
|
37
|
+
body: createTextField({
|
|
38
|
+
personal: { of: "authorId" },
|
|
39
|
+
find: "none",
|
|
40
|
+
}),
|
|
38
41
|
authorId: createTextField({ required: true }),
|
|
39
42
|
},
|
|
40
43
|
table: "pii_comments",
|
|
@@ -42,7 +45,10 @@ const commentEntity = createEntity({
|
|
|
42
45
|
|
|
43
46
|
const brandingEntity = createEntity({
|
|
44
47
|
fields: {
|
|
45
|
-
brandColor: createTextField({
|
|
48
|
+
brandColor: createTextField({
|
|
49
|
+
personal: "tenant",
|
|
50
|
+
find: "none",
|
|
51
|
+
}),
|
|
46
52
|
},
|
|
47
53
|
table: "pii_branding",
|
|
48
54
|
});
|
|
@@ -225,14 +231,14 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
225
231
|
});
|
|
226
232
|
});
|
|
227
233
|
|
|
228
|
-
describe("
|
|
229
|
-
test("
|
|
234
|
+
describe("tenant-owned field encryption (kumiko-platform#457)", () => {
|
|
235
|
+
test("tenant-owned field round-trips through the subject-KMS pipeline", async () => {
|
|
230
236
|
const kms = new InMemoryKmsAdapter();
|
|
231
237
|
const brandingWithAccess = createEntity({
|
|
232
238
|
fields: {
|
|
233
239
|
iban: createTextField({
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
personal: "tenant",
|
|
241
|
+
find: "none",
|
|
236
242
|
access: { read: ["TenantAdmin"] },
|
|
237
243
|
}),
|
|
238
244
|
},
|
|
@@ -250,11 +256,11 @@ describe("piiEncrypted alias (kumiko-platform#457)", () => {
|
|
|
250
256
|
expect(read["iban"]).toBe("DE89370400440532013000");
|
|
251
257
|
});
|
|
252
258
|
|
|
253
|
-
test("
|
|
259
|
+
test("tenant-annotated field becomes unreadable after subject erasure (Art. 17, kumiko-platform#461)", async () => {
|
|
254
260
|
const kms = new InMemoryKmsAdapter();
|
|
255
261
|
const brandingWithAccess = createEntity({
|
|
256
262
|
fields: {
|
|
257
|
-
iban: createTextField({
|
|
263
|
+
iban: createTextField({ personal: "tenant", find: "none" }),
|
|
258
264
|
},
|
|
259
265
|
table: "pii_branding_iban_erasure",
|
|
260
266
|
});
|
|
@@ -263,7 +269,7 @@ describe("piiEncrypted alias (kumiko-platform#457)", () => {
|
|
|
263
269
|
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
|
|
264
270
|
|
|
265
271
|
// Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
|
|
266
|
-
// know or care
|
|
272
|
+
// know or care about the field's `personal` annotation, just the subject.
|
|
267
273
|
await kms.eraseKey({ kind: "tenant", tenantId: UUID_B });
|
|
268
274
|
|
|
269
275
|
const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
|
|
9
9
|
const userLikeEntity = createEntity({
|
|
10
10
|
fields: {
|
|
11
|
-
email: createTextField({ required: true,
|
|
11
|
+
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
12
12
|
role: createTextField(),
|
|
13
13
|
},
|
|
14
14
|
table: "resolver_users",
|
|
@@ -17,7 +17,10 @@ const userLikeEntity = createEntity({
|
|
|
17
17
|
|
|
18
18
|
const commentEntity = createEntity({
|
|
19
19
|
fields: {
|
|
20
|
-
body: createTextField({
|
|
20
|
+
body: createTextField({
|
|
21
|
+
personal: { of: "authorId" },
|
|
22
|
+
find: "none",
|
|
23
|
+
}),
|
|
21
24
|
authorId: createTextField({ required: true }),
|
|
22
25
|
},
|
|
23
26
|
table: "resolver_comments",
|
|
@@ -25,7 +28,10 @@ const commentEntity = createEntity({
|
|
|
25
28
|
|
|
26
29
|
const brandingEntity = createEntity({
|
|
27
30
|
fields: {
|
|
28
|
-
brandColor: createTextField({
|
|
31
|
+
brandColor: createTextField({
|
|
32
|
+
personal: "tenant",
|
|
33
|
+
find: "none",
|
|
34
|
+
}),
|
|
29
35
|
},
|
|
30
36
|
table: "resolver_branding",
|
|
31
37
|
});
|
|
@@ -36,7 +36,7 @@ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
|
36
36
|
const personEntity = createEntity({
|
|
37
37
|
table: "read_bidx_persons",
|
|
38
38
|
fields: {
|
|
39
|
-
email: createTextField({ required: true,
|
|
39
|
+
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
40
40
|
firstName: createTextField(),
|
|
41
41
|
},
|
|
42
42
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { decodeCursor, encodeCursor } from "../cursor";
|
|
2
|
+
import { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "../cursor";
|
|
3
3
|
|
|
4
4
|
describe("encodeCursor / decodeCursor", () => {
|
|
5
5
|
test("round-trips string ids", () => {
|
|
@@ -15,3 +15,28 @@ describe("encodeCursor / decodeCursor", () => {
|
|
|
15
15
|
expect(() => decodeCursor(encodeCursor(""))).toThrow(/Invalid cursor/);
|
|
16
16
|
});
|
|
17
17
|
});
|
|
18
|
+
|
|
19
|
+
describe("encodeKeysetCursor / decodeKeysetCursor", () => {
|
|
20
|
+
test("round-trips a sort value alongside the id", () => {
|
|
21
|
+
const id = "0194a1b2-c3d4-7890-abcd-ef1234567890";
|
|
22
|
+
const decoded = decodeKeysetCursor(encodeKeysetCursor("2026-03-01", id));
|
|
23
|
+
expect(decoded).toEqual({ id, sortValue: "2026-03-01" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("round-trips a null sort value as null, not undefined", () => {
|
|
27
|
+
const id = "0194a1b2-c3d4-7890-abcd-ef1234567890";
|
|
28
|
+
const decoded = decodeKeysetCursor(encodeKeysetCursor(null, id));
|
|
29
|
+
expect(decoded.sortValue).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("decodes a legacy id-only cursor with sortValue undefined", () => {
|
|
33
|
+
const decoded = decodeKeysetCursor(encodeCursor("abc-123"));
|
|
34
|
+
expect(decoded).toEqual({ id: "abc-123", sortValue: undefined });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("treats a JSON payload that isn't a valid keyset cursor as a legacy id", () => {
|
|
38
|
+
const cursor = encodeCursor('{"nope":1}');
|
|
39
|
+
expect(() => decodeKeysetCursor(cursor)).not.toThrow();
|
|
40
|
+
expect(decodeKeysetCursor(cursor)).toEqual({ id: '{"nope":1}', sortValue: undefined });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -49,7 +49,7 @@ const contactEntity = createEntity({
|
|
|
49
49
|
table: "el_contacts",
|
|
50
50
|
fields: {
|
|
51
51
|
name: createTextField({ required: true }),
|
|
52
|
-
email: createTextField({ required: true,
|
|
52
|
+
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
53
53
|
iban: createTextField({ required: true, encrypted: true }),
|
|
54
54
|
},
|
|
55
55
|
});
|
|
@@ -71,7 +71,7 @@ const ownedContactEntity = createEntity({
|
|
|
71
71
|
table: "el_owned_contacts",
|
|
72
72
|
fields: {
|
|
73
73
|
name: createTextField({ required: true }),
|
|
74
|
-
email: createTextField({ required: true,
|
|
74
|
+
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
75
75
|
iban: createTextField({ required: true, encrypted: true }),
|
|
76
76
|
},
|
|
77
77
|
access: { read: { admin: from("user:id", "ownerId") } },
|
|
@@ -85,7 +85,7 @@ const unrestrictedContactEntity = createEntity({
|
|
|
85
85
|
table: "el_unrestricted_contacts",
|
|
86
86
|
fields: {
|
|
87
87
|
name: createTextField({ required: true }),
|
|
88
|
-
email: createTextField({ required: true,
|
|
88
|
+
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
89
89
|
},
|
|
90
90
|
access: { read: { admin: "all", member: "all" } },
|
|
91
91
|
});
|
|
@@ -15,7 +15,10 @@ const entity = createEntity({
|
|
|
15
15
|
table: "source-probe",
|
|
16
16
|
fields: {
|
|
17
17
|
userId: createTextField({ required: true }),
|
|
18
|
-
ip: createTextField({
|
|
18
|
+
ip: createTextField({
|
|
19
|
+
personal: { of: "userId" },
|
|
20
|
+
find: "none",
|
|
21
|
+
}),
|
|
19
22
|
},
|
|
20
23
|
});
|
|
21
24
|
|
|
@@ -20,7 +20,7 @@ describe("event-store-executor-context — encryptForStorage/decryptForRead laye
|
|
|
20
20
|
// Both markers at once — the auth-mfa.totpSecret/recoveryCodes shape
|
|
21
21
|
// that first surfaced the ordering bug (pii-subject-encryption
|
|
22
22
|
// integration test).
|
|
23
|
-
secretNote: createTextField({ encrypted: true,
|
|
23
|
+
secretNote: createTextField({ encrypted: true, personal: { of: "userId" }, find: "none" }),
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
26
|
const encryption = createTestEnvelopeCipher(TEST_KEY);
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
9
9
|
import { asRawClient } from "../../db/query";
|
|
10
|
-
import { createEntity, createNumberField, createTextField } from "../../engine";
|
|
10
|
+
import { createDateField, createEntity, createNumberField, createTextField } from "../../engine";
|
|
11
11
|
import { UnprocessableError } from "../../errors";
|
|
12
12
|
import { createEventsTable } from "../../event-store";
|
|
13
13
|
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
14
14
|
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
15
|
+
import { encodeCursor } from "../cursor";
|
|
15
16
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
16
17
|
import { buildEntityTable } from "../table-builder";
|
|
17
18
|
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
@@ -21,6 +22,7 @@ const entity = createEntity({
|
|
|
21
22
|
fields: {
|
|
22
23
|
title: createTextField({ required: true, sortable: true }),
|
|
23
24
|
rank: createNumberField({ sortable: true }),
|
|
25
|
+
dueDate: createDateField({ sortable: true }),
|
|
24
26
|
},
|
|
25
27
|
});
|
|
26
28
|
const table = buildEntityTable("pagerItem", entity);
|
|
@@ -498,3 +500,144 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
|
|
|
498
500
|
expect(res.rows).toHaveLength(0);
|
|
499
501
|
});
|
|
500
502
|
});
|
|
503
|
+
|
|
504
|
+
describe("event-store-executor.list — keyset cursor mit custom sort (#2265)", () => {
|
|
505
|
+
const exec = createEventStoreExecutor(table, entity, { entityName: "pagerItem" });
|
|
506
|
+
|
|
507
|
+
async function collectPages(
|
|
508
|
+
sort: string,
|
|
509
|
+
direction: "asc" | "desc",
|
|
510
|
+
pageSize: number,
|
|
511
|
+
): Promise<{ ids: string[]; iterations: number }> {
|
|
512
|
+
const ids: string[] = [];
|
|
513
|
+
let cursor: string | undefined;
|
|
514
|
+
let iterations = 0;
|
|
515
|
+
while (iterations < 10) {
|
|
516
|
+
iterations++;
|
|
517
|
+
const res = await exec.list(
|
|
518
|
+
{ limit: pageSize, cursor, sort, sortDirection: direction },
|
|
519
|
+
admin,
|
|
520
|
+
tdb,
|
|
521
|
+
);
|
|
522
|
+
ids.push(...res.rows.map((r) => r["id"] as string));
|
|
523
|
+
if (res.nextCursor === null) break;
|
|
524
|
+
cursor = res.nextCursor;
|
|
525
|
+
}
|
|
526
|
+
return { ids, iterations };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
test("Issue-Repro: Seite 2 dupliziert nicht mehr die letzte Zeile von Seite 1 und überspringt nicht die älteste", async () => {
|
|
530
|
+
await exec.create({ title: "re-1001", dueDate: "2026-01-15" }, admin, tdb);
|
|
531
|
+
await exec.create({ title: "re-1002", dueDate: "2026-02-15" }, admin, tdb);
|
|
532
|
+
await exec.create({ title: "re-1003", dueDate: "2026-03-15" }, admin, tdb);
|
|
533
|
+
|
|
534
|
+
const page1 = await exec.list({ limit: 2, sort: "dueDate", sortDirection: "desc" }, admin, tdb);
|
|
535
|
+
expect(page1.rows.map((r) => r["title"])).toEqual(["re-1003", "re-1002"]);
|
|
536
|
+
expect(page1.nextCursor).not.toBeNull();
|
|
537
|
+
|
|
538
|
+
const page2 = await exec.list(
|
|
539
|
+
{
|
|
540
|
+
limit: 2,
|
|
541
|
+
cursor: page1.nextCursor ?? undefined,
|
|
542
|
+
sort: "dueDate",
|
|
543
|
+
sortDirection: "desc",
|
|
544
|
+
totalCount: true,
|
|
545
|
+
},
|
|
546
|
+
admin,
|
|
547
|
+
tdb,
|
|
548
|
+
);
|
|
549
|
+
expect(page2.rows.map((r) => r["title"])).toEqual(["re-1001"]);
|
|
550
|
+
expect(page2.rows.map((r) => r["title"])).not.toContain("re-1003");
|
|
551
|
+
// total reuses the same WHERE (cursor boundary included) as the page
|
|
552
|
+
// query — this pins that the keyset boundary's extra param doesn't
|
|
553
|
+
// desync the shared `params` array between the two queries.
|
|
554
|
+
expect(page2.total).toBe(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test.each(["asc", "desc"] as const)(
|
|
558
|
+
"Voll-Sweep mit Ties (sort=dueDate, %s) matcht die Referenz-Abfrage exakt",
|
|
559
|
+
async (direction) => {
|
|
560
|
+
// Interleaved seed order (round-robin across dueDates) decorrelates
|
|
561
|
+
// uuidv7 id order from dueDate order — a grouped seed (all of date A,
|
|
562
|
+
// then all of date B, ...) would leave id order and asc-dueDate order
|
|
563
|
+
// coincidentally identical, letting the pre-fix id-only cursor
|
|
564
|
+
// boundary pass this test by accident.
|
|
565
|
+
const dueDates = ["2026-01-10", "2026-02-10", "2026-03-10", "2026-04-10"];
|
|
566
|
+
for (let i = 0; i < 3; i++) {
|
|
567
|
+
for (const dueDate of dueDates) {
|
|
568
|
+
await exec.create({ title: `tie-${dueDate}-${i}`, dueDate }, admin, tdb);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const reference = await exec.list(
|
|
573
|
+
{ limit: 50, sort: "dueDate", sortDirection: direction },
|
|
574
|
+
admin,
|
|
575
|
+
tdb,
|
|
576
|
+
);
|
|
577
|
+
const referenceIds = reference.rows.map((r) => r["id"] as string);
|
|
578
|
+
expect(referenceIds).toHaveLength(12);
|
|
579
|
+
|
|
580
|
+
const { ids, iterations } = await collectPages("dueDate", direction, 5);
|
|
581
|
+
expect(iterations).toBeLessThan(10);
|
|
582
|
+
expect(ids).toEqual(referenceIds);
|
|
583
|
+
expect(new Set(ids).size).toBe(12);
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
test.each(["asc", "desc"] as const)(
|
|
588
|
+
"Voll-Sweep mit NULL dueDate (%s) matcht die Referenz-Abfrage exakt",
|
|
589
|
+
async (direction) => {
|
|
590
|
+
const seedPlan: Array<{ title: string; dueDate?: string }> = [
|
|
591
|
+
{ title: "n-1" },
|
|
592
|
+
{ title: "d-1", dueDate: "2026-01-05" },
|
|
593
|
+
{ title: "n-2" },
|
|
594
|
+
{ title: "d-2", dueDate: "2026-02-05" },
|
|
595
|
+
{ title: "d-3", dueDate: "2026-01-05" },
|
|
596
|
+
{ title: "n-3" },
|
|
597
|
+
{ title: "d-4", dueDate: "2026-03-05" },
|
|
598
|
+
{ title: "n-4" },
|
|
599
|
+
{ title: "d-5", dueDate: "2026-02-05" },
|
|
600
|
+
];
|
|
601
|
+
for (const row of seedPlan) {
|
|
602
|
+
await exec.create(
|
|
603
|
+
row.dueDate === undefined
|
|
604
|
+
? { title: row.title }
|
|
605
|
+
: { title: row.title, dueDate: row.dueDate },
|
|
606
|
+
admin,
|
|
607
|
+
tdb,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const reference = await exec.list(
|
|
612
|
+
{ limit: 50, sort: "dueDate", sortDirection: direction },
|
|
613
|
+
admin,
|
|
614
|
+
tdb,
|
|
615
|
+
);
|
|
616
|
+
const referenceIds = reference.rows.map((r) => r["id"] as string);
|
|
617
|
+
expect(referenceIds).toHaveLength(9);
|
|
618
|
+
|
|
619
|
+
const { ids, iterations } = await collectPages("dueDate", direction, 2);
|
|
620
|
+
expect(iterations).toBeLessThan(10);
|
|
621
|
+
expect(ids).toEqual(referenceIds);
|
|
622
|
+
expect(new Set(ids).size).toBe(9);
|
|
623
|
+
},
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
test("Rückwärtskompatibilität: legacy id-only Cursor + custom sort wirft nicht und fällt auf die id-Grenze zurück", async () => {
|
|
627
|
+
const created: string[] = [];
|
|
628
|
+
for (let i = 0; i < 5; i++) {
|
|
629
|
+
const res = await exec.create({ title: `legacy-${i}`, dueDate: "2026-05-01" }, admin, tdb);
|
|
630
|
+
if (!res.isSuccess) throw new Error("create failed");
|
|
631
|
+
created.push(String(res.data.id));
|
|
632
|
+
}
|
|
633
|
+
const legacyCursor = encodeCursor(created[0] as string);
|
|
634
|
+
|
|
635
|
+
const call = exec.list({ limit: 50, cursor: legacyCursor, sort: "dueDate" }, admin, tdb);
|
|
636
|
+
await expect(call).resolves.toBeDefined();
|
|
637
|
+
|
|
638
|
+
const res = await call;
|
|
639
|
+
const returnedIds = res.rows.map((r) => r["id"] as string);
|
|
640
|
+
expect(returnedIds).not.toContain(created[0]);
|
|
641
|
+
expect(returnedIds).toEqual(created.slice(1));
|
|
642
|
+
});
|
|
643
|
+
});
|
|
@@ -693,8 +693,11 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
|
|
|
693
693
|
const piiEntity = createEntity({
|
|
694
694
|
table: "read_es_exec_pii",
|
|
695
695
|
fields: {
|
|
696
|
-
email: createTextField({ required: true,
|
|
697
|
-
note: createTextField({
|
|
696
|
+
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
697
|
+
note: createTextField({
|
|
698
|
+
personal: { of: "authorId" },
|
|
699
|
+
find: "none",
|
|
700
|
+
}),
|
|
698
701
|
authorId: createTextField(),
|
|
699
702
|
plain: createTextField(),
|
|
700
703
|
},
|
|
@@ -250,7 +250,7 @@ const sensitiveEntity = createEntity({
|
|
|
250
250
|
table: sensitiveTable,
|
|
251
251
|
fields: {
|
|
252
252
|
email: createTextField({ required: true }),
|
|
253
|
-
apiKey: createTextField({
|
|
253
|
+
apiKey: createTextField({ personal: "self", find: "exact" }),
|
|
254
254
|
},
|
|
255
255
|
});
|
|
256
256
|
|
package/src/db/cursor.ts
CHANGED
|
@@ -13,3 +13,35 @@ export function decodeCursor(cursor: string): string {
|
|
|
13
13
|
if (decoded === "") throw new Error(`Invalid cursor: ${cursor}`);
|
|
14
14
|
return decoded;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
type KeysetCursorPayload = { readonly v: string | null; readonly i: string };
|
|
18
|
+
|
|
19
|
+
export type DecodedKeysetCursor = {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
// undefined = legacy id-only cursor still in flight from a client
|
|
22
|
+
readonly sortValue: string | null | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function encodeKeysetCursor(sortValue: string | null, id: string): string {
|
|
26
|
+
return encodeCursor(JSON.stringify({ v: sortValue, i: id } satisfies KeysetCursorPayload));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decodeKeysetCursor(cursor: string): DecodedKeysetCursor {
|
|
30
|
+
const decoded = decodeCursor(cursor);
|
|
31
|
+
return parseKeysetPayload(decoded) ?? { id: decoded, sortValue: undefined };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseKeysetPayload(decoded: string): DecodedKeysetCursor | null {
|
|
35
|
+
if (!decoded.startsWith("{")) return null;
|
|
36
|
+
let raw: unknown;
|
|
37
|
+
try {
|
|
38
|
+
raw = JSON.parse(decoded);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
43
|
+
const { v, i } = raw as Record<string, unknown>;
|
|
44
|
+
if (typeof i !== "string" || i === "") return null;
|
|
45
|
+
if (v !== null && typeof v !== "string") return null;
|
|
46
|
+
return { id: i, sortValue: v };
|
|
47
|
+
}
|
|
@@ -8,7 +8,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
|
8
8
|
import { UnprocessableError } from "../errors";
|
|
9
9
|
import { getStreamVersion } from "../event-store";
|
|
10
10
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
11
|
-
import {
|
|
11
|
+
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
12
12
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
13
13
|
import { buildFilterWhere, type ExecutorContext } from "./event-store-executor-context";
|
|
14
14
|
import { toSnakeCase } from "./table-builder";
|
|
@@ -43,6 +43,54 @@ export function resolveListPagination(payload: {
|
|
|
43
43
|
return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Cursor sort values travel as text and bind as plain string params — Postgres
|
|
47
|
+
// infers the parameter type from the column they are compared against, the same
|
|
48
|
+
// way prepareValue binds a timestamptz as an ISO string. undefined means the
|
|
49
|
+
// driver value has no faithful text form, which downgrades the page to the
|
|
50
|
+
// legacy id-only boundary instead of emitting a wrong one.
|
|
51
|
+
function toCursorSortText(value: unknown): string | null | undefined {
|
|
52
|
+
if (value === undefined) return undefined;
|
|
53
|
+
if (value === null) return null;
|
|
54
|
+
if (typeof value === "string") return value;
|
|
55
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
56
|
+
return String(value);
|
|
57
|
+
}
|
|
58
|
+
if (value instanceof Date) return value.toISOString();
|
|
59
|
+
if (typeof value === "object") {
|
|
60
|
+
const tag = (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag];
|
|
61
|
+
if (typeof tag === "string" && tag.startsWith("Temporal.")) return String(value);
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
|
|
68
|
+
// null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
|
|
69
|
+
// would change the visible order and cost the sort its index.
|
|
70
|
+
function keysetBoundarySql(
|
|
71
|
+
sortCol: string,
|
|
72
|
+
idCol: string,
|
|
73
|
+
cursor: { readonly id: string; readonly sortValue: string | null },
|
|
74
|
+
descending: boolean,
|
|
75
|
+
params: unknown[],
|
|
76
|
+
): string {
|
|
77
|
+
params.push(cursor.id);
|
|
78
|
+
const afterId = `${idCol} > $${params.length}`;
|
|
79
|
+
const nullsComeFirst = descending;
|
|
80
|
+
if (cursor.sortValue === null) {
|
|
81
|
+
return nullsComeFirst
|
|
82
|
+
? `(${sortCol} IS NOT NULL OR ${afterId})`
|
|
83
|
+
: `(${sortCol} IS NULL AND ${afterId})`;
|
|
84
|
+
}
|
|
85
|
+
params.push(cursor.sortValue);
|
|
86
|
+
const sortParam = `$${params.length}`;
|
|
87
|
+
const beyond = `${sortCol} ${descending ? "<" : ">"} ${sortParam}`;
|
|
88
|
+
const tie = `(${sortCol} = ${sortParam} AND ${afterId})`;
|
|
89
|
+
return nullsComeFirst
|
|
90
|
+
? `(${sortCol} IS NOT NULL AND (${beyond} OR ${tie}))`
|
|
91
|
+
: `(${sortCol} IS NULL OR ${beyond} OR ${tie})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
46
94
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
47
95
|
const {
|
|
48
96
|
table,
|
|
@@ -105,8 +153,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
105
153
|
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
106
154
|
const whereSql: string[] = [];
|
|
107
155
|
const params: unknown[] = [];
|
|
108
|
-
const
|
|
109
|
-
|
|
156
|
+
const physicalCol = (field: string): string =>
|
|
157
|
+
(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
158
|
+
const colSql = (field: string): string => `"${physicalCol(field)}"`;
|
|
159
|
+
const sortField = payload.sort && table[payload.sort] ? payload.sort : undefined;
|
|
160
|
+
const sortDescending = payload.sortDirection === "desc";
|
|
110
161
|
|
|
111
162
|
// Tenant-Filter (replicates TenantDb's readWhere semantics).
|
|
112
163
|
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
@@ -117,8 +168,21 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
117
168
|
whereSql.push(`${colSql("isDeleted")} = FALSE`);
|
|
118
169
|
}
|
|
119
170
|
if (payload.cursor) {
|
|
120
|
-
|
|
121
|
-
|
|
171
|
+
const cursor = decodeKeysetCursor(payload.cursor);
|
|
172
|
+
if (sortField === undefined || cursor.sortValue === undefined) {
|
|
173
|
+
params.push(cursor.id);
|
|
174
|
+
whereSql.push(`${colSql("id")} > $${params.length}`);
|
|
175
|
+
} else {
|
|
176
|
+
whereSql.push(
|
|
177
|
+
keysetBoundarySql(
|
|
178
|
+
colSql(sortField),
|
|
179
|
+
colSql("id"),
|
|
180
|
+
{ id: cursor.id, sortValue: cursor.sortValue },
|
|
181
|
+
sortDescending,
|
|
182
|
+
params,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
122
186
|
}
|
|
123
187
|
if (filterIds) {
|
|
124
188
|
const placeholders = filterIds.map((id) => {
|
|
@@ -199,8 +263,8 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
199
263
|
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
200
264
|
|
|
201
265
|
const orderByClause =
|
|
202
|
-
|
|
203
|
-
? ` ORDER BY ${colSql(
|
|
266
|
+
sortField !== undefined
|
|
267
|
+
? ` ORDER BY ${colSql(sortField)} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
|
|
204
268
|
: ` ORDER BY ${colSql("id")} ASC`;
|
|
205
269
|
const useOffset = !payload.cursor && offset > 0;
|
|
206
270
|
const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
|
|
@@ -234,8 +298,15 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
234
298
|
}
|
|
235
299
|
|
|
236
300
|
const lastRow = rows[rows.length - 1];
|
|
237
|
-
const
|
|
238
|
-
|
|
301
|
+
const lastRaw = rawRows[rawRows.length - 1];
|
|
302
|
+
let nextCursor: string | null = null;
|
|
303
|
+
if (rows.length === limit && lastRow && lastRaw) {
|
|
304
|
+
const cursorId = lastRow["id"] as string; // @cast-boundary engine-payload
|
|
305
|
+
const sortText =
|
|
306
|
+
sortField === undefined ? undefined : toCursorSortText(lastRaw[physicalCol(sortField)]);
|
|
307
|
+
nextCursor =
|
|
308
|
+
sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
309
|
+
}
|
|
239
310
|
|
|
240
311
|
// total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
|
|
241
312
|
// Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
|
package/src/db/index.ts
CHANGED
|
@@ -12,8 +12,8 @@ export type {
|
|
|
12
12
|
DbTx,
|
|
13
13
|
} from "./connection";
|
|
14
14
|
export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection";
|
|
15
|
-
export type { CursorQueryOptions, CursorResult } from "./cursor";
|
|
16
|
-
export { decodeCursor, encodeCursor } from "./cursor";
|
|
15
|
+
export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor";
|
|
16
|
+
export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
17
17
|
export type { SchemaTable, SelectQuery, TableColumns } from "./dialect";
|
|
18
18
|
export {
|
|
19
19
|
bigint,
|
package/src/db/pg-error.ts
CHANGED
|
@@ -49,6 +49,13 @@ export function isLockNotAvailable(e: unknown): boolean {
|
|
|
49
49
|
return extractPgError(e)?.code === "55P03";
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// PG SQLSTATE 42P01 — "undefined table". A projection table for an entity
|
|
53
|
+
// that was never mounted/rebuilt (or was dropped since) — callers treat
|
|
54
|
+
// this as "no row found", not as a fatal error.
|
|
55
|
+
export function isUndefinedTable(e: unknown): boolean {
|
|
56
|
+
return extractPgError(e)?.code === "42P01";
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
export function constraintOf(e: unknown): string | undefined {
|
|
53
60
|
return extractPgError(e)?.constraint_name;
|
|
54
61
|
}
|