@cosmicdrift/kumiko-framework 0.209.1 → 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.
Files changed (35) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  4. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  5. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  6. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  7. package/src/db/__tests__/cursor.test.ts +26 -1
  8. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  9. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  10. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  12. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  13. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  14. package/src/db/cursor.ts +32 -0
  15. package/src/db/event-store-executor-read.ts +80 -9
  16. package/src/db/index.ts +2 -2
  17. package/src/db/pg-error.ts +7 -0
  18. package/src/db/queries/backfill-pii.ts +188 -18
  19. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  20. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  21. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  22. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  23. package/src/engine/__tests__/field-access.test.ts +1 -23
  24. package/src/engine/__tests__/store-table.test.ts +4 -1
  25. package/src/engine/boot-validator/pii-retention.ts +22 -54
  26. package/src/engine/build-config-feature-schema.ts +9 -1
  27. package/src/engine/factories.ts +108 -30
  28. package/src/engine/field-access.ts +2 -13
  29. package/src/engine/index.ts +7 -1
  30. package/src/engine/types/index.ts +7 -1
  31. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  32. package/src/files/file-ref-entity.ts +2 -2
  33. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  34. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  35. 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.209.1",
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.209.1",
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.1",
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({ required: true, maxLength: 160, allowPlaintext: "is-business-data" }),
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, pii: true, lookupable: 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, pii: 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({ userOwned: { ownerField: "authorId" } }),
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({ tenantOwned: true }),
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("piiEncrypted alias (kumiko-platform#457)", () => {
229
- test("piiEncrypted + tenantOwned round-trips through the same subject-KMS pipeline", async () => {
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
- piiEncrypted: true,
235
- tenantOwned: true,
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("piiEncrypted field is covered by subject erasure (Art. 17, kumiko-platform#461)", async () => {
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({ piiEncrypted: true, tenantOwned: true }),
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 that this field is piiEncrypted vs. plain tenantOwned.
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, pii: 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({ userOwned: { ownerField: "authorId" } }),
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({ tenantOwned: true }),
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, pii: true, lookupable: 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, tenantOwned: 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, tenantOwned: 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, tenantOwned: 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({ userOwned: { ownerField: "userId" } }),
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, userOwned: { ownerField: "userId" } }),
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, pii: true }),
697
- note: createTextField({ userOwned: { ownerField: "authorId" } }),
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({ sensitive: true, pii: true, lookupable: true }),
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 { decodeCursor, encodeCursor } from "./cursor";
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 colSql = (field: string): string =>
109
- `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
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
- params.push(decodeCursor(payload.cursor));
121
- whereSql.push(`${colSql("id")} > $${params.length}`);
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
- payload.sort && table[payload.sort]
203
- ? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}, ${colSql("id")} ASC`
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 nextCursor =
238
- rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
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,
@@ -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
  }