@cosmicdrift/kumiko-framework 0.209.1 → 0.211.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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/changes.json +56 -0
  4. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  6. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  7. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  8. package/src/db/__tests__/cursor.test.ts +26 -1
  9. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  10. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  11. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  12. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  13. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  14. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  15. package/src/db/cursor.ts +32 -0
  16. package/src/db/event-store-executor-read.ts +80 -9
  17. package/src/db/index.ts +2 -2
  18. package/src/db/pg-error.ts +7 -0
  19. package/src/db/queries/backfill-pii.ts +188 -18
  20. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  22. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  23. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  24. package/src/engine/__tests__/field-access.test.ts +1 -23
  25. package/src/engine/__tests__/store-table.test.ts +4 -1
  26. package/src/engine/boot-validator/pii-retention.ts +22 -54
  27. package/src/engine/build-config-feature-schema.ts +9 -1
  28. package/src/engine/factories.ts +108 -30
  29. package/src/engine/field-access.ts +2 -13
  30. package/src/engine/index.ts +7 -1
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  33. package/src/files/file-ref-entity.ts +2 -2
  34. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  35. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  36. 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.211.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.211.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.211.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
 
package/src/changes.json CHANGED
@@ -1,4 +1,60 @@
1
1
  [
2
+ {
3
+ "version": "0.209.1",
4
+ "type": "breaking",
5
+ "title": "Job runs no longer go through the event store; read_job_runs table renamed to store_job_runs (fw#2243).",
6
+ "detail": "Job runs (jobRun) no longer go through the event store. Every job execution used to append a run-started + run-completed/run-failed event replayed through two inline projections — in the busiest apps this was ~99% of all events ever written, for data nothing else replays or subscribes to. onJobStart/onJobComplete/onJobFailed now write straight into the (renamed) store_job_runs / store_job_run_logs tables, with a new daily jobs:job:retention-cleanup job (retentionDays, default 30) purging old rows so the tables don't grow forever.",
7
+ "migration": "Breaking for raw-SQL consumers: the table is renamed read_job_runs → store_job_runs (store_job_run_logs is unchanged). The migration drops read_job_runs outright — old run history is not preserved, it was operational/debug data, not a system of record. Apps that only use the shipped job-runs-screen/jobs:query:* handlers are unaffected; apps with a raw SQL dependency on read_job_runs need a follow-up on their side."
8
+ },
9
+ {
10
+ "version": "0.201.0",
11
+ "type": "breaking",
12
+ "title": "IdempotencyGuard.check()/.store() gain a discriminated result + token param on top of the 0.198.0 signature (fw#2139).",
13
+ "detail": "Fixes two idempotency-lock races that could let a duplicate request re-run a write handler or silently overwrite a fresher cached result. `waitTimeoutMs` (how long a duplicate request waits for the in-flight one) is now clamped to always exceed `pendingTtlSeconds` (the in-progress lock's own TTL) — previously the defaults (30s lock vs. 25s wait) let a retry give up and re-execute the handler while the original call was still legitimately running. `IdempotencyGuard.store()` now does an atomic compare-and-swap against the exact lock token the calling run acquired (Redis EVAL) instead of an unconditional SET, so a stale, slow-finishing run can no longer stomp the result a reclaiming run already persisted after the lock expired. `IdempotencyGuard.check()` now returns a discriminated `{ status: \"cached\", result }` / `{ status: \"acquired\", token }` union instead of `string | null`, and `store()` takes the acquired token as a new parameter.",
14
+ "migration": "Layered on top of the 0.198.0 signature change: check() is now check(tenantId, userId, requestId) returning { status: \"cached\", result } | { status: \"acquired\", token }; store() is now store(tenantId, userId, requestId, token). Both call sites in this repo (dispatch-batch.ts, the dispatcher test mock) are already updated; any code outside this repo calling IdempotencyGuard directly needs the same update."
15
+ },
16
+ {
17
+ "version": "0.201.0",
18
+ "type": "breaking",
19
+ "title": "GET /files/:id now sniffs bytes and serves svg/txt/csv/json/md as application/octet-stream instead of inline (fw#2140).",
20
+ "detail": "GET /files/:id served the stored mimeType as Content-Type without verifying it against the file's actual bytes — a client can declare any MIME at upload time, so an attacker could upload real HTML/SVG content and have it served back with a trusted-looking Content-Type from the app origin, enabling stored XSS. Uploads themselves are still accepted regardless of declared MIME (this is unchanged); the fix hardens serving instead. The download route now sniffs the file's magic bytes and only serves the sniffed Content-Type inline when it matches a known-safe binary signature (png/jpeg/gif/webp/pdf) AND matches the declared MIME from upload. Anything else — including a genuine mismatch, or file types with no reliable binary signature such as svg/txt/csv/json/md — is now served as application/octet-stream. This also adds X-Content-Type-Options: nosniff to GET /files/:id, which previously had none.",
21
+ "migration": "Breaking for consumers that render uploaded svg/txt/csv/json/md files inline (e.g. an <img src> pointing at GET /files/:id): those now download as application/octet-stream instead of rendering. Route such content through a purpose-built safe viewer if inline rendering is required."
22
+ },
23
+ {
24
+ "version": "0.198.0",
25
+ "type": "breaking",
26
+ "title": "IdempotencyGuard.check/.store signature changed to (tenantId, userId, requestId); SqlExpression is branded (fw#2049).",
27
+ "detail": "Security hardening (audit \"Welle 2\"): closes a request-supplied-JSON-can-forge-raw-SQL path and a cross-tenant idempotency-cache collision. `SqlExpression` is now branded — only the `sql` template tag and `sql.raw(...)` produce a value the query layer recognizes as raw SQL; an object literal built by hand (`{ kind: \"sql-expr\", sql: ..., params: ... }`) is no longer treated as raw SQL and gets bound as an ordinary JSON parameter instead, surfacing as a broken query rather than a silent vulnerability. `IdempotencyGuard.check`/`.store` moved from `(requestId)` to `(tenantId, userId, requestId)` so the idempotency cache can no longer be hit across tenants/users by an attacker who guesses or replays a requestId; the Redis key format changed from `${prefix}${requestId}` to `${prefix}${tenantId}:${userId}:${requestId}` with no compatibility shim.",
28
+ "migration": "Replace any hand-built SqlExpression object literal with the `sql` tag or `sql.raw(...)`. Any custom IdempotencyGuard implementation, or code calling `.check`/`.store` directly (outside the dispatcher's own runBatch, which already updated), needs the new (tenantId, userId, requestId) signature. On deploy, in-flight idempotent retries older than the request's own retry window may execute a second time — same as a first-ever request, not a correctness issue, just not a cache hit."
29
+ },
30
+ {
31
+ "version": "0.198.0",
32
+ "type": "breaking",
33
+ "title": "event-store-executor.list() now throws 422 search_adapter_not_wired instead of returning unfiltered results (fw#2032).",
34
+ "detail": "event-store-executor.list() silently dropped payload.search when no SearchAdapter was wired (neither at build time via options.searchAdapter nor at runtime via runtimeOptions.searchAdapter) — the list came back unfiltered, indistinguishable from a real search result. Now throws UnprocessableError (code: \"unprocessable\", details.reason: \"search_adapter_not_wired\", details.entity) instead.",
35
+ "migration": "Breaking for consumers whose entities are searchable but have no SearchAdapter wired: a search request that used to silently no-op now returns a 422. Wire a SearchAdapter (e.g. Meilisearch) for the entity, or stop marking the field/screen searchable."
36
+ },
37
+ {
38
+ "version": "0.198.0",
39
+ "type": "breaking",
40
+ "title": "NavIconKey closed union replaces icon?: string on nav/config-mask definitions (fw#2055).",
41
+ "detail": "NavDefinition.icon, ContentCollectionDefinition.nav.icon, ScreenNavSugar.icon and ConfigMask.icon were all icon?: string — any typo (icon: \"seting\") compiled fine and silently fell back to a dot in the sidebar. New NavIconKey union (@cosmicdrift/kumiko-types/nav-icon, re-exported from @cosmicdrift/kumiko-framework/{engine,ui-types}) types all four against the closed set of keys the web renderer actually registers, so an unregistered icon key is now a compile error at the r.nav()/r.screen({ nav })/config-mask call site instead of a missing icon at runtime. packages/renderer-web's NAV_ICONS map is checked against the same union via `as const satisfies Record<NavIconKey, …>`, so the type and the map can no longer drift.",
42
+ "migration": "Breaking for any app that passes an icon key outside the vocabulary in packages/types/src/nav-icon.ts — such a call site will fail to compile after this bump. Fix the typo or add the missing key to both NavIconKey and renderer-web's NAV_ICONS map in the same change."
43
+ },
44
+ {
45
+ "version": "0.193.0",
46
+ "type": "breaking",
47
+ "title": "Image fields get named derived variants; ImageFieldDef/ImagesFieldDef.thumbnails removed (fw#1973).",
48
+ "detail": "createImageField now accepts variants: Record<string, VariantSpec> — boot-validated named derived-image specs, served via GET /api/files/:id/variant/:name behind the same tenant + access guard as the download. A request carries only a NAME, never a spec, so no caller can drive an arbitrary render. The edit-form preview loads the first declared variant instead of the original.",
49
+ "migration": "ImageFieldDef.thumbnails / ImagesFieldDef.thumbnails are removed — the flag was never read by anything. Replace any reliance on it with a declared variants entry."
50
+ },
51
+ {
52
+ "version": "0.189.0",
53
+ "type": "breaking",
54
+ "title": "createDateField now backs a real Postgres DATE column, round-trips as Temporal.PlainDate (fw#1924).",
55
+ "detail": "type:\"date\" fields were silently aliased onto the same instant()/TIMESTAMPTZ column as type:\"timestamp\": reads returned a full ISO instant (\"2026-03-15T00:00:00Z\"), writes expected a bare \"yyyy-mm-dd\" string bound to a timestamptz column through the session's TimeZone — both directions were timezone-dependent for what is meant to be a pure calendar-day value. A date field now serializes as \"2026-03-15\" (Temporal.PlainDate's own toJSON()); a non-form client that Instant-parses a date field's JSON value now throws. Write shape is unchanged (bare \"yyyy-mm-dd\").",
56
+ "migration": "Managed (event-sourced projection) tables: the generator emits DROP TABLE + CREATE TABLE and replays from the event log automatically — factor in replay cost for entities with a large event history. Unmanaged (store_*, direct-write) tables: the generator emits an in-place ALTER TABLE … ALTER COLUMN … TYPE date USING (col AT TIME ZONE 'UTC')::date, anchored explicitly at UTC — do not hand-write a bare ALTER COLUMN … TYPE date without USING, which falls back to Postgres's session-TimeZone-dependent implicit cast."
57
+ },
2
58
  {
3
59
  "version": "0.177.0",
4
60
  "type": "breaking",
@@ -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
+ }