@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -9,7 +9,7 @@
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
11
11
  import { asRawClient } from "../../db/query";
12
- import { createEntity, createTextField } from "../../engine";
12
+ import { createEntity, createTextField, from } from "../../engine";
13
13
  import type { EntityDefinition } from "../../engine/types";
14
14
  import { createEventsTable } from "../../event-store";
15
15
  import { createInMemorySearchAdapter } from "../../search";
@@ -70,6 +70,45 @@ const tenantMetaEntity = createEntity({
70
70
  });
71
71
  const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
72
72
 
73
+ // PII target with a derived search index — ciphertext at rest, plaintext
74
+ // mirrored into the adapter; `ownerId` + `access.read` gives a real row-owned rule.
75
+ const piiCustomerEntity = createEntity({
76
+ table: "read_ref_search_pii_customers",
77
+ fields: {
78
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
79
+ name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
80
+ },
81
+ access: {
82
+ read: { Admin: "all", User: from("user:id", "ownerId") },
83
+ },
84
+ });
85
+ const piiCustomerTable = buildEntityTable("refSearchPiiCustomer", piiCustomerEntity);
86
+
87
+ const piiOrderEntity = createEntity({
88
+ table: "read_ref_search_pii_orders",
89
+ fields: {
90
+ note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
91
+ customerId: {
92
+ type: "reference",
93
+ entity: "refSearchPiiCustomer",
94
+ labelField: "name",
95
+ searchable: true,
96
+ },
97
+ },
98
+ });
99
+ const piiOrderTable = buildEntityTable("refSearchPiiOrder", piiOrderEntity);
100
+
101
+ function resolvePiiEntity(name: string): EntityDefinition | undefined {
102
+ return name === "refSearchPiiCustomer" ? piiCustomerEntity : undefined;
103
+ }
104
+
105
+ const piiReferenceSearch = {
106
+ fields: [
107
+ { fieldName: "customerId", targetEntityName: "refSearchPiiCustomer", labelField: "name" },
108
+ ],
109
+ resolveEntity: resolvePiiEntity,
110
+ };
111
+
73
112
  function resolveEntity(name: string): EntityDefinition | undefined {
74
113
  return name === "refSearchCustomer" ? customerEntity : undefined;
75
114
  }
@@ -104,6 +143,9 @@ const orderExec = createEventStoreExecutor(orderTable, orderEntity, {
104
143
  const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
105
144
  entityName: "refSearchRestrictedOrder",
106
145
  });
146
+ const piiOrderExec = createEventStoreExecutor(piiOrderTable, piiOrderEntity, {
147
+ entityName: "refSearchPiiOrder",
148
+ });
107
149
 
108
150
  beforeAll(async () => {
109
151
  await ensureTemporalPolyfill();
@@ -111,6 +153,8 @@ beforeAll(async () => {
111
153
  await unsafeCreateEntityTable(testDb.db, customerEntity, "refSearchCustomer");
112
154
  await unsafeCreateEntityTable(testDb.db, orderEntity, "refSearchOrder");
113
155
  await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSearchRestrictedOrder");
156
+ await unsafeCreateEntityTable(testDb.db, piiCustomerEntity, "refSearchPiiCustomer");
157
+ await unsafeCreateEntityTable(testDb.db, piiOrderEntity, "refSearchPiiOrder");
114
158
  await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
115
159
  await createEventsTable(testDb.db);
116
160
  tdbA = createTenantDb(testDb.db, admin.tenantId);
@@ -124,7 +168,8 @@ afterAll(async () => {
124
168
  beforeEach(async () => {
125
169
  await asRawClient(testDb.db).unsafe(
126
170
  "TRUNCATE kumiko_events, read_ref_search_customers, read_ref_search_orders, " +
127
- "read_ref_search_restricted_orders, read_tenants RESTART IDENTITY CASCADE",
171
+ "read_ref_search_restricted_orders, read_ref_search_pii_customers, " +
172
+ "read_ref_search_pii_orders, read_tenants RESTART IDENTITY CASCADE",
128
173
  );
129
174
  });
130
175
 
@@ -379,3 +424,222 @@ describe("event-store-executor.list — searchable reference fields (fw#2660)",
379
424
  }
380
425
  });
381
426
  });
427
+
428
+ describe("event-store-executor.list — searchable reference to an encrypted/PII labelField via the search index", () => {
429
+ test('matches via the target\'s derived search index when the labelField is PII (find: "fuzzy")', async () => {
430
+ const [contact] = await seedRows(testDb.db, piiCustomerTable, [
431
+ {
432
+ id: crypto.randomUUID(),
433
+ tenantId: admin.tenantId,
434
+ ownerId: admin.id,
435
+ name: "Confidential Contact",
436
+ },
437
+ ]);
438
+ const [order] = await seedRows(testDb.db, piiOrderTable, [
439
+ {
440
+ id: crypto.randomUUID(),
441
+ tenantId: admin.tenantId,
442
+ note: "totally unrelated",
443
+ customerId: (contact as { id: string }).id,
444
+ },
445
+ ]);
446
+
447
+ const searchAdapter = createInMemorySearchAdapter();
448
+ // One shared per-tenant index across every entity type (real Meilisearch
449
+ // adapter parity: one index per tenant, `_type` is only a filter
450
+ // attribute) — `searchableFields` must list every entity's own
451
+ // searchable field, not just this test's own entity's.
452
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
453
+ // Plaintext label lives in the search adapter under the target's own
454
+ // type — never a raw ILIKE scan of the (encrypted-at-rest) column.
455
+ await searchAdapter.index(admin.tenantId, {
456
+ entityType: "refSearchPiiCustomer",
457
+ entityId: (contact as { id: string }).id,
458
+ weight: 1,
459
+ fields: { name: "Confidential Contact" },
460
+ });
461
+
462
+ const res = await piiOrderExec.list({ search: "confidential" }, admin, tdbA, {
463
+ searchAdapter,
464
+ referenceSearch: piiReferenceSearch,
465
+ });
466
+ expect(res.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
467
+ });
468
+
469
+ test("drops the reference clause above 200 search-index matches — native search still applies", async () => {
470
+ const fooCustomers = Array.from({ length: 201 }, (_, i) => ({
471
+ id: crypto.randomUUID(),
472
+ tenantId: admin.tenantId,
473
+ ownerId: admin.id,
474
+ name: `Foo Contact ${i}`,
475
+ }));
476
+ await seedRows(testDb.db, piiCustomerTable, fooCustomers);
477
+
478
+ const [refOnlyOrder, textMatchOrder] = await seedRows(testDb.db, piiOrderTable, [
479
+ {
480
+ id: crypto.randomUUID(),
481
+ tenantId: admin.tenantId,
482
+ note: "no keyword here",
483
+ customerId: fooCustomers[5]?.id,
484
+ },
485
+ {
486
+ id: crypto.randomUUID(),
487
+ tenantId: admin.tenantId,
488
+ note: "foo appears right here",
489
+ customerId: fooCustomers[10]?.id,
490
+ },
491
+ ]);
492
+
493
+ const searchAdapter = createInMemorySearchAdapter();
494
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
495
+ for (const c of fooCustomers) {
496
+ await searchAdapter.index(admin.tenantId, {
497
+ entityType: "refSearchPiiCustomer",
498
+ entityId: c.id,
499
+ weight: 1,
500
+ fields: { name: c.name },
501
+ });
502
+ }
503
+ for (const o of [refOnlyOrder, textMatchOrder]) {
504
+ const row = o as { id: string; note: string };
505
+ await searchAdapter.index(admin.tenantId, {
506
+ entityType: "refSearchPiiOrder",
507
+ entityId: row.id,
508
+ weight: 1,
509
+ fields: { note: row.note },
510
+ });
511
+ }
512
+
513
+ const res = await piiOrderExec.list({ search: "foo" }, admin, tdbA, {
514
+ searchAdapter,
515
+ referenceSearch: piiReferenceSearch,
516
+ });
517
+ expect(res.rows.map((r) => r["id"])).toEqual([(textMatchOrder as { id: string }).id]);
518
+ });
519
+
520
+ test("a target label match in a foreign tenant's search index never surfaces a row (mandatory)", async () => {
521
+ const [contact] = await seedRows(testDb.db, piiCustomerTable, [
522
+ {
523
+ id: crypto.randomUUID(),
524
+ tenantId: otherTenantAdmin.tenantId,
525
+ ownerId: otherTenantAdmin.id,
526
+ name: "Foreign Contact",
527
+ },
528
+ ]);
529
+ await seedRows(testDb.db, piiOrderTable, [
530
+ {
531
+ id: crypto.randomUUID(),
532
+ tenantId: otherTenantAdmin.tenantId,
533
+ note: "unrelated",
534
+ customerId: (contact as { id: string }).id,
535
+ },
536
+ ]);
537
+
538
+ const searchAdapter = createInMemorySearchAdapter();
539
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
540
+ // Indexed only under the OTHER tenant's search index — admin's own
541
+ // search must never see it, regardless of the FK row's actual tenantId.
542
+ await searchAdapter.index(otherTenantAdmin.tenantId, {
543
+ entityType: "refSearchPiiCustomer",
544
+ entityId: (contact as { id: string }).id,
545
+ weight: 1,
546
+ fields: { name: "Foreign Contact" },
547
+ });
548
+
549
+ const res = await piiOrderExec.list({ search: "foreign" }, admin, tdbA, {
550
+ searchAdapter,
551
+ referenceSearch: piiReferenceSearch,
552
+ });
553
+ expect(res.rows).toHaveLength(0);
554
+ });
555
+
556
+ test("row-level ownership on the target entity intersects the search-index candidates, same as the target's own list would", async () => {
557
+ const [ownRow, otherRow] = await seedRows(testDb.db, piiCustomerTable, [
558
+ {
559
+ id: crypto.randomUUID(),
560
+ tenantId: admin.tenantId,
561
+ ownerId: TestUsers.user.id,
562
+ name: "Shared Label",
563
+ },
564
+ {
565
+ id: crypto.randomUUID(),
566
+ tenantId: admin.tenantId,
567
+ ownerId: admin.id,
568
+ name: "Shared Label",
569
+ },
570
+ ]);
571
+ const [ownOrder, otherOrder] = await seedRows(testDb.db, piiOrderTable, [
572
+ {
573
+ id: crypto.randomUUID(),
574
+ tenantId: admin.tenantId,
575
+ note: "n1",
576
+ customerId: (ownRow as { id: string }).id,
577
+ },
578
+ {
579
+ id: crypto.randomUUID(),
580
+ tenantId: admin.tenantId,
581
+ note: "n2",
582
+ customerId: (otherRow as { id: string }).id,
583
+ },
584
+ ]);
585
+
586
+ const searchAdapter = createInMemorySearchAdapter();
587
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
588
+ for (const row of [ownRow, otherRow]) {
589
+ const r = row as { id: string; name: string };
590
+ await searchAdapter.index(admin.tenantId, {
591
+ entityType: "refSearchPiiCustomer",
592
+ entityId: r.id,
593
+ weight: 1,
594
+ fields: { name: r.name },
595
+ });
596
+ }
597
+
598
+ // Both customers match "shared" in the index — the search index itself
599
+ // has no ownership awareness. Only the order pointing at the customer
600
+ // TestUsers.user (role "User") actually owns via ownerId may surface.
601
+ const res = await piiOrderExec.list({ search: "shared" }, TestUsers.user, tdbA, {
602
+ searchAdapter,
603
+ referenceSearch: piiReferenceSearch,
604
+ });
605
+ expect(res.rows.map((r) => r["id"])).toEqual([(ownOrder as { id: string }).id]);
606
+ expect(res.rows.map((r) => r["id"])).not.toContain((otherOrder as { id: string }).id);
607
+ });
608
+
609
+ test("a role with no read grant on the target entity gets no reference-match hit at all", async () => {
610
+ const [contact] = await seedRows(testDb.db, piiCustomerTable, [
611
+ {
612
+ id: crypto.randomUUID(),
613
+ tenantId: admin.tenantId,
614
+ ownerId: admin.id,
615
+ name: "Driver Blind Spot",
616
+ },
617
+ ]);
618
+ await seedRows(testDb.db, piiOrderTable, [
619
+ {
620
+ id: crypto.randomUUID(),
621
+ tenantId: admin.tenantId,
622
+ note: "unrelated",
623
+ customerId: (contact as { id: string }).id,
624
+ },
625
+ ]);
626
+
627
+ const searchAdapter = createInMemorySearchAdapter();
628
+ await searchAdapter.configure(admin.tenantId, { searchableFields: ["note", "name"] });
629
+ await searchAdapter.index(admin.tenantId, {
630
+ entityType: "refSearchPiiCustomer",
631
+ entityId: (contact as { id: string }).id,
632
+ weight: 1,
633
+ fields: { name: "Driver Blind Spot" },
634
+ });
635
+
636
+ // "Driver" has no rule at all on piiCustomerEntity.access.read →
637
+ // buildOwnershipClause returns "empty" — no reference-match hit, no
638
+ // matter what the search index found.
639
+ const res = await piiOrderExec.list({ search: "driver" }, TestUsers.driver, tdbA, {
640
+ searchAdapter,
641
+ referenceSearch: piiReferenceSearch,
642
+ });
643
+ expect(res.rows).toHaveLength(0);
644
+ });
645
+ });
@@ -385,6 +385,83 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
385
385
  });
386
386
  });
387
387
 
388
+ // =============================================================================
389
+ // Explicit-id create: cross-tenant isolation, no resurrection of a deleted row
390
+ // =============================================================================
391
+
392
+ const deletedIdEntity = createEntity({
393
+ table: "read_es_write_deleted_id",
394
+ fields: {
395
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
396
+ },
397
+ softDelete: true,
398
+ });
399
+ const deletedIdTable = buildEntityTable("esWriteDeletedId", deletedIdEntity);
400
+
401
+ describe("event-store-executor write-verbs — explicit-id tenant isolation + soft-delete", () => {
402
+ const crud = createEventStoreExecutor(deletedIdTable, deletedIdEntity, {
403
+ entityName: "esWriteDeletedId",
404
+ });
405
+ let otherTenantDb: TenantDb;
406
+
407
+ beforeAll(async () => {
408
+ await unsafeCreateEntityTable(testDb.db, deletedIdEntity, "esWriteDeletedId");
409
+ otherTenantDb = createTenantDb(testDb.db, TestUsers.otherTenant.tenantId);
410
+ });
411
+
412
+ beforeEach(async () => {
413
+ await asRawClient(testDb.db).unsafe(
414
+ `TRUNCATE kumiko_events, read_es_write_deleted_id RESTART IDENTITY CASCADE`,
415
+ );
416
+ });
417
+
418
+ test("create with an id already used by another tenant → conflict, tenant A's row untouched, no leak", async () => {
419
+ // The event log itself is tenant-scoped (UNIQUE (tenant_id, aggregate_id,
420
+ // version)) and would happily let tenant B open its own stream at the
421
+ // same id — but every entity's projection table has a plain `id` primary
422
+ // key (table-builder.ts), shared across all tenants for that entity
423
+ // type. So a same-id create for a different tenant fails at the
424
+ // projection-insert step with `unique_violation`, not `version_conflict`
425
+ // — a different, still-typed error, and the mapped error's details carry
426
+ // only entityName/constraintName, nothing that identifies tenant A's row.
427
+ const first = await crud.create({ email: "tenant-a@test.de" }, admin, tdb);
428
+ if (!first.isSuccess) throw new Error("setup failed");
429
+
430
+ const other = await crud.create(
431
+ { id: first.data.id, email: "tenant-b@test.de" },
432
+ TestUsers.otherTenant,
433
+ otherTenantDb,
434
+ );
435
+ expect(other.isSuccess).toBe(false);
436
+ if (other.isSuccess) return;
437
+ expect(other.error.code).toBe("unique_violation");
438
+ expect(JSON.stringify(other.error.details)).not.toContain(admin.tenantId);
439
+
440
+ const row = await asRawClient(testDb.db).unsafe(
441
+ `SELECT email FROM read_es_write_deleted_id WHERE id = $1`,
442
+ [first.data.id],
443
+ );
444
+ expect((row as unknown as { email: string }[])[0]?.email).toBe("tenant-a@test.de");
445
+ });
446
+
447
+ test("create with the id of a soft-deleted row → version_conflict, no resurrection", async () => {
448
+ const created = await crud.create({ email: "gone@test.de" }, admin, tdb);
449
+ if (!created.isSuccess) throw new Error("setup failed");
450
+
451
+ const deleted = await crud.delete({ id: created.data.id }, admin, tdb);
452
+ expect(deleted.isSuccess).toBe(true);
453
+
454
+ const resurrect = await crud.create(
455
+ { id: created.data.id, email: "back-from-the-dead@test.de" },
456
+ admin,
457
+ tdb,
458
+ );
459
+ expect(resurrect.isSuccess).toBe(false);
460
+ if (resurrect.isSuccess) return;
461
+ expect(resurrect.error.code).toBe("version_conflict");
462
+ });
463
+ });
464
+
388
465
  // =============================================================================
389
466
  // Concurrent update race → EventStoreVersionConflict catch + entityCache.del
390
467
  // on forget/restore (create/update/delete already exercise cache in the
@@ -807,7 +807,7 @@ describe("event-store-executor — pii subject encryption", () => {
807
807
  0,
808
808
  );
809
809
  if (!storedEvent) throw new Error("no persisted event for the aggregate");
810
- const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, tdb.raw);
810
+ const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, testDb.db);
811
811
  expect(applied.kind).toBe("applied");
812
812
 
813
813
  const rawRows = (await asRawClient(testDb.db).unsafe(
@@ -365,6 +365,23 @@ ALTER TABLE "widgets" OWNER TO app_user;`,
365
365
  }
366
366
  });
367
367
 
368
+ test("FORCE / NO FORCE ROW LEVEL SECURITY is shape-neutral (fw#2907)", () => {
369
+ const dir = tmpMigrationsDir();
370
+ try {
371
+ write(
372
+ dir,
373
+ "0001_init.sql",
374
+ `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY, "title" text);
375
+ ALTER TABLE "widgets" FORCE ROW LEVEL SECURITY;
376
+ ALTER TABLE widgets NO FORCE ROW LEVEL SECURITY;`,
377
+ );
378
+ const replayed = replayMigrationsDir(dir);
379
+ expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "title"]);
380
+ } finally {
381
+ rmSync(dir, { recursive: true, force: true });
382
+ }
383
+ });
384
+
368
385
  test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
369
386
  const dir = tmpMigrationsDir();
370
387
  try {
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
4
+ import { InternalError } from "../../errors";
5
+ import { testTenantId } from "../../stack";
6
+ import type { DbRunner } from "../connection";
7
+ import * as dbIndex from "../index";
8
+ import { acknowledgeConventionCrossTenant, createTenantDb, withUnsafeRawGrant } from "../tenant-db";
9
+ import { tenantDbRunner } from "../tenant-db-runner";
10
+
11
+ const tenantId = testTenantId(1);
12
+ const REASON = "operator scan across every tenant";
13
+
14
+ function fakeRunner(): DbRunner {
15
+ return {
16
+ unsafe: async () => [],
17
+ begin: async () => {
18
+ throw new Error("begin not used in these tests");
19
+ },
20
+ } as unknown as DbRunner;
21
+ }
22
+
23
+ describe("acknowledgeConventionCrossTenant", () => {
24
+ test("returns a system-mode TenantDb with the same tenantId", () => {
25
+ const tdb = createTenantDb(fakeRunner(), tenantId);
26
+ const result = acknowledgeConventionCrossTenant(tdb, REASON);
27
+ expect(result.mode).toBe("system");
28
+ expect(result.tenantId).toBe(tenantId);
29
+ });
30
+
31
+ test("reports acknowledge-cross-tenant exactly once via grants.report", () => {
32
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
33
+ const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
34
+ report: (kind, reason) => {
35
+ reports.push({ kind, reason });
36
+ },
37
+ });
38
+ acknowledgeConventionCrossTenant(tdb, REASON);
39
+ expect(reports).toEqual([{ kind: "acknowledge-cross-tenant", reason: REASON }]);
40
+ });
41
+
42
+ test("throws for an empty reason", () => {
43
+ const tdb = createTenantDb(fakeRunner(), tenantId);
44
+ expect(() => acknowledgeConventionCrossTenant(tdb, "")).toThrow();
45
+ expect(() => acknowledgeConventionCrossTenant(tdb, " ")).toThrow();
46
+ });
47
+
48
+ test("throws InternalError for a TenantDb not built by createTenantDb", () => {
49
+ const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
50
+ expect(() => acknowledgeConventionCrossTenant(handBuilt, REASON)).toThrow(InternalError);
51
+ });
52
+
53
+ test("still resolves after withUnsafeRawGrant rebinds the TenantDb", () => {
54
+ const runner = fakeRunner();
55
+ const tdb = createTenantDb(runner, tenantId);
56
+ const regranted = withUnsafeRawGrant(tdb, { reason: "unrelated unsafeRaw grant" });
57
+ const result = acknowledgeConventionCrossTenant(regranted, REASON);
58
+ expect(result.mode).toBe("system");
59
+ expect(tenantDbRunner(result)).toBe(runner);
60
+ });
61
+
62
+ test("tenantDbRunner(result) resolves to the original runner", () => {
63
+ const runner = fakeRunner();
64
+ const tdb = createTenantDb(runner, tenantId);
65
+ const result = acknowledgeConventionCrossTenant(tdb, REASON);
66
+ expect(tenantDbRunner(result)).toBe(runner);
67
+ });
68
+
69
+ test("db/index.ts does not export acknowledgeConventionCrossTenant", () => {
70
+ expect("acknowledgeConventionCrossTenant" in dbIndex).toBe(false);
71
+ });
72
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
4
+ import { AccessDeniedError, InternalError } from "../../errors";
5
+ import { testTenantId } from "../../stack";
6
+ import type { DbRunner } from "../connection";
7
+ import {
8
+ createTenantDb,
9
+ createUncheckedSystemDb,
10
+ unsafeRawForDeclaredStep,
11
+ withUnsafeRawGrant,
12
+ } from "../tenant-db";
13
+
14
+ const tenantId = testTenantId(1);
15
+ const REASON = "declared step forwarding unsafeRaw";
16
+
17
+ function fakeRunner(): DbRunner {
18
+ return {
19
+ unsafe: async () => [],
20
+ begin: async () => {
21
+ throw new Error("begin not used in these tests");
22
+ },
23
+ } as unknown as DbRunner;
24
+ }
25
+
26
+ describe("unsafeRawForDeclaredStep", () => {
27
+ test("throws AccessDeniedError without a grant, without reporting", () => {
28
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
29
+ const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
30
+ report: (kind, reason) => {
31
+ reports.push({ kind, reason });
32
+ },
33
+ });
34
+
35
+ expect(() => unsafeRawForDeclaredStep(tdb, REASON)).toThrow(AccessDeniedError);
36
+ expect(reports).toEqual([]);
37
+ });
38
+
39
+ test("returns exactly the bound runner and reports unsafe-raw exactly once, with a grant", () => {
40
+ const runner = fakeRunner();
41
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
42
+ const tdb = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
43
+ unsafeRaw: { reason: "handler declared unsafeRaw" },
44
+ report: (kind, reason) => {
45
+ reports.push({ kind, reason });
46
+ },
47
+ });
48
+
49
+ expect(unsafeRawForDeclaredStep(tdb, REASON)).toBe(runner);
50
+ expect(reports).toEqual([{ kind: "unsafe-raw", reason: REASON }]);
51
+ });
52
+
53
+ test("throws for an empty or whitespace-only reason", () => {
54
+ const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
55
+ unsafeRaw: { reason: "handler declared unsafeRaw" },
56
+ });
57
+
58
+ expect(() => unsafeRawForDeclaredStep(tdb, "")).toThrow(/non-empty reason/);
59
+ expect(() => unsafeRawForDeclaredStep(tdb, " ")).toThrow(/non-empty reason/);
60
+ });
61
+
62
+ test("throws InternalError for a TenantDb not built by createTenantDb", () => {
63
+ const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
64
+ expect(() => unsafeRawForDeclaredStep(handBuilt, REASON)).toThrow(InternalError);
65
+ });
66
+
67
+ test("throws InternalError for a throwing guard Proxy, without the proxy's get trap firing", () => {
68
+ let getTrapFired = false;
69
+ const guardProxy = new Proxy(
70
+ {},
71
+ {
72
+ get() {
73
+ getTrapFired = true;
74
+ throw new Error("systemScope guard: property access denied");
75
+ },
76
+ },
77
+ ) as unknown as TenantDb;
78
+
79
+ expect(() => unsafeRawForDeclaredStep(guardProxy, REASON)).toThrow(InternalError);
80
+ expect(getTrapFired).toBe(false);
81
+ });
82
+
83
+ test("resolves to the bound runner for a createUncheckedSystemDb holder, reporting once", () => {
84
+ const runner = fakeRunner();
85
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
86
+ const systemDb = createTenantDb(runner, tenantId, "system");
87
+ const unchecked = createUncheckedSystemDb(systemDb, undefined, (kind, reason) => {
88
+ reports.push({ kind, reason });
89
+ });
90
+
91
+ expect(unsafeRawForDeclaredStep(unchecked, REASON)).toBe(runner);
92
+ expect(reports).toEqual([{ kind: "unsafe-raw", reason: REASON }]);
93
+ });
94
+
95
+ test("still resolves after withUnsafeRawGrant rebinds the TenantDb", () => {
96
+ const runner = fakeRunner();
97
+ const tdb = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
98
+ unsafeRaw: { reason: "original grant" },
99
+ });
100
+ const regranted = withUnsafeRawGrant(tdb, { reason: "rebind grant" });
101
+
102
+ expect(unsafeRawForDeclaredStep(regranted, REASON)).toBe(runner);
103
+ });
104
+ });