@cosmicdrift/kumiko-framework 0.289.0 → 0.291.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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  6. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  7. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  8. package/src/api/request-context.ts +31 -0
  9. package/src/api/request-id-middleware.ts +2 -1
  10. package/src/api/routes.ts +35 -3
  11. package/src/api/server.ts +30 -4
  12. package/src/changes.json +69 -0
  13. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  14. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  15. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  16. package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
  17. package/src/crypto/subject-resolver.ts +25 -8
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/queries/shadow-swap.ts +35 -0
  27. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  28. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  29. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
  30. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  31. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  32. package/src/engine/__tests__/engine.test.ts +87 -0
  33. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  34. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  35. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  36. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  37. package/src/engine/boot-validator/action-wiring.ts +2 -1
  38. package/src/engine/boot-validator/entity-handler.ts +44 -0
  39. package/src/engine/boot-validator/index.ts +7 -2
  40. package/src/engine/boot-validator/pii-retention.ts +8 -0
  41. package/src/engine/boot-validator/screens.ts +54 -12
  42. package/src/engine/create-app.ts +54 -0
  43. package/src/engine/feature-config-events-jobs.ts +19 -0
  44. package/src/engine/index.ts +2 -0
  45. package/src/engine/qualified-name.ts +9 -0
  46. package/src/engine/screen-helpers.ts +1 -0
  47. package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
  48. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  49. package/src/event-store/event-store.ts +23 -2
  50. package/src/i18n/required-surface-keys.ts +1 -0
  51. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  52. package/src/jobs/index.ts +7 -1
  53. package/src/jobs/job-runner.ts +104 -6
  54. package/src/logging/utils.ts +14 -1
  55. package/src/observability/index.ts +1 -0
  56. package/src/observability/standard-metrics.ts +20 -0
  57. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  58. package/src/pipeline/active-membership.ts +10 -4
  59. package/src/pipeline/append-event-core.ts +2 -11
  60. package/src/pipeline/dispatch-shared.ts +17 -5
  61. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  62. package/src/pipeline/projection-rebuild.ts +7 -0
  63. package/src/schema-cli.ts +21 -0
  64. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
  65. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  66. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  67. package/src/testing/e2e-generator.ts +4 -3
  68. package/src/ui-types/index.ts +2 -0
@@ -62,7 +62,7 @@ describe("r.bootCheck / validateFeatureBootChecks", () => {
62
62
  });
63
63
 
64
64
  test("no PII field → boot succeeds even without the companion", () => {
65
- const noPiiFields = { text: createTextField() };
65
+ const noPiiFields = { text: createTextField({ personal: false, reason: "test_fixture" }) };
66
66
  const noPii = defineFeature("prompt-store-no-pii", (r) => {
67
67
  r.entity("note", createEntity({ fields: noPiiFields }));
68
68
  r.bootCheck(({ features }) => {
@@ -40,6 +40,17 @@ import type { LongTextFieldDef, TextFieldDef } from "../types";
40
40
  const rawField = <T>(f: T) => f as unknown as TextFieldDef;
41
41
  const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
42
42
 
43
+ // The heuristics under test only fire without a personal stance; after #2810
44
+ // the factories can no longer produce that shape.
45
+ const unannotatedText: TextFieldDef = {
46
+ type: "text",
47
+ maxLength: 200,
48
+ required: false,
49
+ searchable: false,
50
+ sortable: false,
51
+ };
52
+ const unannotatedLongText: LongTextFieldDef = { type: "longText", required: false };
53
+
43
54
  // Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
44
55
  // Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
45
56
  // wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
@@ -136,7 +147,7 @@ describe("validateBoot — PII annotations", () => {
136
147
  createEntity({
137
148
  fields: {
138
149
  confused: rawField({
139
- ...createTextField(),
150
+ ...unannotatedText,
140
151
  pii: true,
141
152
  tenantOwned: true,
142
153
  }),
@@ -153,7 +164,7 @@ describe("validateBoot — PII annotations", () => {
153
164
  "vault",
154
165
  createEntity({
155
166
  fields: {
156
- apiToken: createTextField({ sensitive: true }),
167
+ apiToken: { ...unannotatedText, sensitive: true },
157
168
  },
158
169
  }),
159
170
  );
@@ -188,7 +199,7 @@ describe("validateBoot — PII annotations", () => {
188
199
  "vault",
189
200
  createEntity({
190
201
  fields: {
191
- apiToken: createTextField({ sensitive: true, encrypted: true }),
202
+ apiToken: { ...unannotatedText, sensitive: true, encrypted: true },
192
203
  },
193
204
  }),
194
205
  );
@@ -228,7 +239,7 @@ describe("validateBoot — PII annotations", () => {
228
239
  personal: { of: "authorId" },
229
240
  find: "none",
230
241
  }),
231
- authorId: createTextField(),
242
+ authorId: { ...unannotatedText },
232
243
  },
233
244
  }),
234
245
  );
@@ -258,7 +269,7 @@ describe("validateBoot — PII annotations", () => {
258
269
 
259
270
  test("userOwned.ownerField referencing non-user entity warns", () => {
260
271
  const feature = defineFeature("test", (r) => {
261
- r.entity("employee", createEntity({ fields: { name: createTextField() } }));
272
+ r.entity("employee", createEntity({ fields: { name: { ...unannotatedText } } }));
262
273
  stubListHandler(r, "employee");
263
274
  r.entity(
264
275
  "personalNote",
@@ -286,7 +297,7 @@ describe("validateBoot — PII annotations", () => {
286
297
  "thing",
287
298
  createEntity({
288
299
  fields: {
289
- email: createTextField(),
300
+ email: { ...unannotatedText },
290
301
  },
291
302
  }),
292
303
  );
@@ -304,7 +315,7 @@ describe("validateBoot — PII annotations", () => {
304
315
  "thing",
305
316
  createEntity({
306
317
  fields: {
307
- body: createLongTextField(),
318
+ body: { ...unannotatedLongText },
308
319
  },
309
320
  }),
310
321
  );
@@ -322,7 +333,7 @@ describe("validateBoot — PII annotations", () => {
322
333
  "thing",
323
334
  createEntity({
324
335
  fields: {
325
- authorId: createTextField(),
336
+ authorId: { ...unannotatedText },
326
337
  },
327
338
  }),
328
339
  );
@@ -563,7 +574,7 @@ describe("validateBoot — PII annotations", () => {
563
574
  "product",
564
575
  createEntity({
565
576
  fields: {
566
- name: createTextField(),
577
+ name: { ...unannotatedText },
567
578
  },
568
579
  }),
569
580
  );
@@ -581,7 +592,7 @@ describe("validateBoot — PII annotations", () => {
581
592
  "person",
582
593
  createEntity({
583
594
  fields: {
584
- displayName: createTextField(),
595
+ displayName: { ...unannotatedText },
585
596
  },
586
597
  }),
587
598
  );
@@ -592,6 +603,128 @@ describe("validateBoot — PII annotations", () => {
592
603
  );
593
604
  expect(matchingWarn).toBeDefined();
594
605
  });
606
+
607
+ // --- #2918: deprecation warning for text fields without any stance ---
608
+
609
+ const NO_STANCE = "declares no personal stance";
610
+
611
+ test("text field without a personal stance warns, naming feature, entity and field", () => {
612
+ const feature = defineFeature("test", (r) => {
613
+ r.entity(
614
+ "invoice",
615
+ createEntity({
616
+ fields: {
617
+ externalRef: { ...unannotatedText },
618
+ },
619
+ }),
620
+ );
621
+ });
622
+ validateBoot([feature]);
623
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
624
+ String(args[0]).includes(NO_STANCE),
625
+ );
626
+ expect(matchingWarn).toBeDefined();
627
+ const message = String((matchingWarn as unknown[])[0]);
628
+ expect(message).toContain("[Feature test]");
629
+ expect(message).toContain('Field "externalRef" on entity "invoice"');
630
+ expect(message).toContain('{ reason: "..." }');
631
+ });
632
+
633
+ test("longText field without a personal stance warns", () => {
634
+ const feature = defineFeature("test", (r) => {
635
+ r.entity(
636
+ "invoice",
637
+ createEntity({
638
+ fields: {
639
+ remarks: { ...unannotatedLongText },
640
+ },
641
+ }),
642
+ );
643
+ });
644
+ validateBoot([feature]);
645
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
646
+ String(args[0]).includes(NO_STANCE),
647
+ );
648
+ expect(matchingWarn).toBeDefined();
649
+ });
650
+
651
+ test("personal: false with a reason silences the stance warning", () => {
652
+ const feature = defineFeature("test", (r) => {
653
+ r.entity(
654
+ "invoice",
655
+ createEntity({
656
+ fields: {
657
+ externalRef: createTextField({
658
+ personal: false,
659
+ reason: "is_business_data",
660
+ }),
661
+ },
662
+ }),
663
+ );
664
+ });
665
+ validateBoot([feature]);
666
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
667
+ String(args[0]).includes(NO_STANCE),
668
+ );
669
+ expect(matchingWarn).toBeUndefined();
670
+ });
671
+
672
+ test("personal: self silences the stance warning", () => {
673
+ const feature = defineFeature("test", (r) => {
674
+ r.entity(
675
+ "invoice",
676
+ createEntity({
677
+ fields: {
678
+ externalRef: createTextField({
679
+ personal: "self",
680
+ find: "none",
681
+ }),
682
+ },
683
+ }),
684
+ );
685
+ });
686
+ validateBoot([feature]);
687
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
688
+ String(args[0]).includes(NO_STANCE),
689
+ );
690
+ expect(matchingWarn).toBeUndefined();
691
+ });
692
+
693
+ test("personal: ref silences the stance warning", () => {
694
+ const feature = defineFeature("test", (r) => {
695
+ r.entity(
696
+ "invoice",
697
+ createEntity({
698
+ fields: {
699
+ ownerRef: createTextField({ personal: "ref" }),
700
+ },
701
+ }),
702
+ );
703
+ });
704
+ validateBoot([feature]);
705
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
706
+ String(args[0]).includes(NO_STANCE),
707
+ );
708
+ expect(matchingWarn).toBeUndefined();
709
+ });
710
+
711
+ test("number field without a personal stance does not warn", () => {
712
+ const feature = defineFeature("test", (r) => {
713
+ r.entity(
714
+ "invoice",
715
+ createEntity({
716
+ fields: {
717
+ total: { type: "number", required: false },
718
+ },
719
+ }),
720
+ );
721
+ });
722
+ validateBoot([feature]);
723
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
724
+ String(args[0]).includes(NO_STANCE),
725
+ );
726
+ expect(matchingWarn).toBeUndefined();
727
+ });
595
728
  });
596
729
 
597
730
  describe("validateBoot — retention", () => {
@@ -626,7 +759,7 @@ describe("validateBoot — retention", () => {
626
759
  "auditEvent",
627
760
  createEntity({
628
761
  fields: {
629
- note: createTextField(),
762
+ note: { ...unannotatedText },
630
763
  },
631
764
  retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
632
765
  }),
@@ -641,7 +774,7 @@ describe("validateBoot — retention", () => {
641
774
  "thing",
642
775
  createEntity({
643
776
  fields: {
644
- note: createTextField(),
777
+ note: { ...unannotatedText },
645
778
  },
646
779
  retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
647
780
  }),
@@ -852,7 +985,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
852
985
  createEntity({
853
986
  fields: {
854
987
  slug: rawField({
855
- ...createTextField(),
988
+ ...unannotatedText,
856
989
  lookupable: true,
857
990
  }),
858
991
  },
@@ -872,7 +1005,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
872
1005
  ...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
873
1006
  lookupable: true,
874
1007
  }),
875
- ownerId: createTextField({ required: true }),
1008
+ ownerId: { ...unannotatedText, required: true },
876
1009
  },
877
1010
  }),
878
1011
  );
@@ -922,7 +1055,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
922
1055
  createEntity({
923
1056
  fields: {
924
1057
  passwordHash: rawField({
925
- ...createTextField(),
1058
+ ...unannotatedText,
926
1059
  pii: true,
927
1060
  sensitive: true,
928
1061
  searchable: true,
@@ -2506,6 +2506,116 @@ describe("boot-validator", () => {
2506
2506
  );
2507
2507
  });
2508
2508
 
2509
+ // fw#2839: an actionForm has no entity, so a money field there can't
2510
+ // inherit entity.defaultCurrency — undeclared, the renderer seeds a bare
2511
+ // `0` and the handler's zod schema rejects the submit.
2512
+ describe("money field currency source (fw#2839)", () => {
2513
+ const moneyForm = (currency?: unknown) =>
2514
+ makeFeature({
2515
+ fields: { amount: { type: "money", ...(currency !== undefined && { currency }) } },
2516
+ sections: [{ title: "Payment", fields: ["amount"] }],
2517
+ });
2518
+
2519
+ test("money field ohne currency → Throw, nennt Screen und Feld", () => {
2520
+ expect(() => validateBoot([moneyForm()])).toThrow(
2521
+ /Screen "approve-invoice" \(actionForm\) money field "amount" must declare where its currency comes from/,
2522
+ );
2523
+ });
2524
+
2525
+ test("Fehlermeldung nennt beide gültigen Formen wörtlich", () => {
2526
+ expect(() => validateBoot([moneyForm()])).toThrow(
2527
+ /currency: \{ kind: "literal", code: "EUR" \}[\s\S]*currency: \{ kind: "tenant" \}/,
2528
+ );
2529
+ });
2530
+
2531
+ test("currency: { kind: 'literal', code } → kein Throw", () => {
2532
+ expect(() => validateBoot([moneyForm({ kind: "literal", code: "EUR" })])).not.toThrow();
2533
+ });
2534
+
2535
+ test("currency: { kind: 'tenant' } → kein Throw", () => {
2536
+ expect(() => validateBoot([moneyForm({ kind: "tenant" })])).not.toThrow();
2537
+ });
2538
+
2539
+ test("literal mit leerem code → Throw", () => {
2540
+ expect(() => validateBoot([moneyForm({ kind: "literal", code: " " })])).toThrow(
2541
+ /empty or non-string `code`/,
2542
+ );
2543
+ });
2544
+
2545
+ test("unbekannter kind → Throw", () => {
2546
+ expect(() => validateBoot([moneyForm({ kind: "entityDefault" })])).toThrow(
2547
+ /unknown currency kind "entityDefault"/,
2548
+ );
2549
+ });
2550
+
2551
+ test("non-money Felder bleiben unberührt", () => {
2552
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
2553
+ });
2554
+
2555
+ // The check sits in validateFormFieldsMap, the walk actionForm,
2556
+ // secretMint and secretMint's confirm step all share.
2557
+ function mintFeature(
2558
+ fields: Record<string, unknown>,
2559
+ confirmFields?: Record<string, unknown>,
2560
+ ) {
2561
+ return defineFeature("shop", (r) => {
2562
+ r.writeHandler({
2563
+ name: "token:mint",
2564
+ schema: { _type: "stub" } as never,
2565
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
2566
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
2567
+ });
2568
+ r.screen({
2569
+ id: "mint-token",
2570
+ type: "secretMint",
2571
+ handler: "shop:write:token:mint",
2572
+ fields: fields as never,
2573
+ layout: { sections: [{ title: "Mint", fields: Object.keys(fields) }] as never },
2574
+ reveal: { fields: [{ field: "token", label: "Token" }] },
2575
+ ...(confirmFields !== undefined && {
2576
+ confirm: {
2577
+ handler: "shop:write:token:mint",
2578
+ fields: confirmFields as never,
2579
+ layout: {
2580
+ sections: [{ title: "Confirm", fields: Object.keys(confirmFields) }] as never,
2581
+ },
2582
+ },
2583
+ }),
2584
+ });
2585
+ });
2586
+ }
2587
+
2588
+ test("secretMint money field ohne currency → Throw", () => {
2589
+ expect(() => validateBoot([mintFeature({ fee: { type: "money" } })])).toThrow(
2590
+ /Screen "mint-token" \(secretMint\) money field "fee" must declare where its currency comes from/,
2591
+ );
2592
+ });
2593
+
2594
+ test("secretMint confirm-Step money field ohne currency → Throw", () => {
2595
+ expect(() =>
2596
+ validateBoot([
2597
+ mintFeature(
2598
+ { fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
2599
+ { topUp: { type: "money" } },
2600
+ ),
2601
+ ]),
2602
+ ).toThrow(
2603
+ /Screen "mint-token" \(secretMint confirm\) money field "topUp" must declare where its currency comes from/,
2604
+ );
2605
+ });
2606
+
2607
+ test("secretMint mit deklarierten Quellen in beiden Steps → kein Throw", () => {
2608
+ expect(() =>
2609
+ validateBoot([
2610
+ mintFeature(
2611
+ { fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
2612
+ { topUp: { type: "money", currency: { kind: "tenant" } } },
2613
+ ),
2614
+ ]),
2615
+ ).not.toThrow();
2616
+ });
2617
+ });
2618
+
2509
2619
  test("layout.sections leer → Throw", () => {
2510
2620
  expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
2511
2621
  /has an empty sections list/,
@@ -3846,6 +3956,122 @@ describe("boot-validator", () => {
3846
3956
  );
3847
3957
  });
3848
3958
 
3959
+ test("reference optionsQuery naming a registered handler → kein Throw (fw#2780)", () => {
3960
+ const features = [
3961
+ defineFeature("shop", (r) => {
3962
+ r.entity(
3963
+ "customer",
3964
+ createEntity({
3965
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
3966
+ }),
3967
+ );
3968
+ stubListHandler(r, "customer");
3969
+ r.queryHandler({
3970
+ name: "customer:options",
3971
+ schema: z.object({}),
3972
+ handler: async () => ({ rows: [] }) as never,
3973
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
3974
+ });
3975
+ r.entity(
3976
+ "order",
3977
+ createEntity({
3978
+ fields: {
3979
+ customerId: {
3980
+ type: "reference",
3981
+ entity: "customer",
3982
+ optionsQuery: "shop:query:customer:options",
3983
+ },
3984
+ },
3985
+ }),
3986
+ );
3987
+ }),
3988
+ ];
3989
+ expect(() => validateBoot(features)).not.toThrow();
3990
+ });
3991
+
3992
+ test("reference optionsQuery auf unregistrierten Handler → Throw (fw#2780)", () => {
3993
+ const features = [
3994
+ defineFeature("shop", (r) => {
3995
+ r.entity(
3996
+ "customer",
3997
+ createEntity({
3998
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
3999
+ }),
4000
+ );
4001
+ stubListHandler(r, "customer");
4002
+ r.entity(
4003
+ "order",
4004
+ createEntity({
4005
+ fields: {
4006
+ customerId: {
4007
+ type: "reference",
4008
+ entity: "customer",
4009
+ optionsQuery: "shop:query:customer:typo",
4010
+ },
4011
+ },
4012
+ }),
4013
+ );
4014
+ }),
4015
+ ];
4016
+ expect(() => validateBoot(features)).toThrow(
4017
+ /Reference field "customerId" on entity "order" declares optionsQuery "shop:query:customer:typo" which is not a registered query-handler/,
4018
+ );
4019
+ });
4020
+
4021
+ test("leerer optionsQuery → Throw (fw#2780)", () => {
4022
+ const features = [
4023
+ defineFeature("shop", (r) => {
4024
+ r.entity(
4025
+ "customer",
4026
+ createEntity({
4027
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
4028
+ }),
4029
+ );
4030
+ stubListHandler(r, "customer");
4031
+ r.entity(
4032
+ "order",
4033
+ createEntity({
4034
+ fields: {
4035
+ customerId: { type: "reference", entity: "customer", optionsQuery: "" },
4036
+ },
4037
+ }),
4038
+ );
4039
+ }),
4040
+ ];
4041
+ expect(() => validateBoot(features)).toThrow(/has an empty optionsQuery/);
4042
+ });
4043
+
4044
+ test("reference sub-field optionsQuery auf unregistrierten Handler → Throw mit parent.child-Pfad (fw#2780)", () => {
4045
+ const features = [
4046
+ defineFeature("shop", (r) => {
4047
+ r.entity(
4048
+ "product",
4049
+ createEntity({
4050
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
4051
+ }),
4052
+ );
4053
+ stubListHandler(r, "product");
4054
+ r.entity(
4055
+ "invoice",
4056
+ createEntity({
4057
+ fields: {
4058
+ lines: createEmbeddedListField({
4059
+ productId: {
4060
+ type: "reference",
4061
+ entity: "product",
4062
+ optionsQuery: "shop:query:product:typo",
4063
+ },
4064
+ }),
4065
+ },
4066
+ }),
4067
+ );
4068
+ }),
4069
+ ];
4070
+ expect(() => validateBoot(features)).toThrow(
4071
+ /Reference field "lines\.productId" on entity "invoice" declares optionsQuery "shop:query:product:typo"/,
4072
+ );
4073
+ });
4074
+
3849
4075
  test("reference sub-field labelField referencing an unknown field → Throw with the parent.child field path", () => {
3850
4076
  const features = [
3851
4077
  defineFeature("shop", (r) => {
@@ -644,6 +644,24 @@ describe("buildAppSchema", () => {
644
644
  expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
645
645
  });
646
646
 
647
+ test("money field's `currency: { kind: 'literal', code }` declaration survives the projection (fw#2839)", () => {
648
+ const entity = {
649
+ defaultCurrency: "USD",
650
+ fields: { price: { type: "money", currency: { kind: "literal", code: "USD" } } },
651
+ } as unknown as EntityDefinition;
652
+
653
+ const f = defineFeature("ent", (r) => {
654
+ r.entity("thing", entity);
655
+ });
656
+ const app = buildAppSchema(createRegistry([f]));
657
+ const projected = app.features[0]!.entities["thing"] as unknown as {
658
+ fields: Record<string, Record<string, unknown>>;
659
+ };
660
+
661
+ expect(projected.fields["price"]?.["currency"]).toEqual({ kind: "literal", code: "USD" });
662
+ expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
663
+ });
664
+
647
665
  test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
648
666
  // Regression: all of these are read by the edit view-model (password
649
667
  // masking, wall-clock input, upload constraints, preview variant, derived
@@ -909,6 +909,45 @@ describe("createRegistry", () => {
909
909
  ]);
910
910
  });
911
911
 
912
+ test("optionsQuery leaves searchable/sortable on the labelField column (fw#2780)", () => {
913
+ const feature = defineFeature("crm", (r) => {
914
+ r.entity(
915
+ "customer",
916
+ createEntity({
917
+ table: "Customers",
918
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
919
+ }),
920
+ );
921
+ r.queryHandler("customer:options", z.object({}), async () => [], {
922
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
923
+ });
924
+ r.entity(
925
+ "order",
926
+ createEntity({
927
+ table: "Orders",
928
+ fields: {
929
+ customerId: {
930
+ type: "reference",
931
+ entity: "customer",
932
+ labelField: "name",
933
+ optionsQuery: "crm:query:customer:options",
934
+ searchable: true,
935
+ sortable: true,
936
+ },
937
+ },
938
+ }),
939
+ );
940
+ });
941
+
942
+ const registry = createRegistry([feature]);
943
+ expect(registry.getSortableReferences("order")).toEqual([
944
+ { fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
945
+ ]);
946
+ expect(registry.getSearchableReferences("order")).toEqual([
947
+ { fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
948
+ ]);
949
+ });
950
+
912
951
  test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
913
952
  const feature = defineFeature("crm", (r) => {
914
953
  r.entity(
@@ -1204,6 +1243,54 @@ describe("createApp", () => {
1204
1243
  );
1205
1244
  });
1206
1245
 
1246
+ // fw#2839: a literal currency source is only meaningful for a code the app
1247
+ // knows — a typo would otherwise render and submit an unformattable amount.
1248
+ function literalCurrencyFeature(code: string) {
1249
+ return defineFeature("test", (r) => {
1250
+ r.entity(
1251
+ "invoice",
1252
+ createEntity({
1253
+ table: "Invoices",
1254
+ defaultCurrency: "EUR",
1255
+ fields: { total: { type: "money", currency: { kind: "literal", code } } },
1256
+ } as never),
1257
+ );
1258
+ });
1259
+ }
1260
+
1261
+ test("rejects currency: { kind: 'literal' } with a code outside the currencies list", () => {
1262
+ expect(() =>
1263
+ createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EURO")] }),
1264
+ ).toThrow(/code: "EURO" \} which is not in the currencies list/);
1265
+ });
1266
+
1267
+ test("accepts currency: { kind: 'literal' } with a code from the currencies list", () => {
1268
+ expect(() =>
1269
+ createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EUR")] }),
1270
+ ).not.toThrow();
1271
+ });
1272
+
1273
+ test("rejects an unknown literal code on an actionForm screen field too", () => {
1274
+ const feature = defineFeature("test", (r) => {
1275
+ r.writeHandler({
1276
+ name: "invoice:pay",
1277
+ schema: { _type: "stub" } as never,
1278
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
1279
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
1280
+ });
1281
+ r.screen({
1282
+ id: "invoice-pay",
1283
+ type: "actionForm",
1284
+ handler: "test:write:invoice:pay",
1285
+ fields: { amount: { type: "money", currency: { kind: "literal", code: "EURO" } } } as never,
1286
+ layout: { sections: [{ title: "Pay", fields: ["amount"] }] } as never,
1287
+ });
1288
+ });
1289
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1290
+ /Screen "invoice-pay" in feature "test", money field "amount"/,
1291
+ );
1292
+ });
1293
+
1207
1294
  // hasMoneyField used to only look at top-level fields, so an entity whose
1208
1295
  // only money lives inside an embedded-list sub-schema (e.g. invoice lines
1209
1296
  // with no top-level money field) slipped past this check — its cells and
@@ -20,8 +20,12 @@ const contactEntity = createEntity({
20
20
  // hook) writes it. secretNote's ownership rule checks authorId, so
21
21
  // create only succeeds if the hook ran BEFORE the field-ownership check
22
22
  // (kumiko-framework#1672 — see also event-store-executor-write.ts).
23
- authorId: createTextField(),
24
- secretNote: createTextField({ access: { write: { User: from("user:id", "authorId") } } }),
23
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
24
+ secretNote: createTextField({
25
+ personal: false,
26
+ reason: "test_fixture",
27
+ access: { write: { User: from("user:id", "authorId") } },
28
+ }),
25
29
  },
26
30
  });
27
31
 
@@ -41,7 +41,12 @@ describe("createLongTextField — runtime shape", () => {
41
41
  });
42
42
 
43
43
  test("encrypted + sensitive flags type-allowed", () => {
44
- const f = createLongTextField({ encrypted: true, sensitive: true });
44
+ const f = createLongTextField({
45
+ personal: false,
46
+ reason: "test_fixture",
47
+ encrypted: true,
48
+ sensitive: true,
49
+ });
45
50
  expect(f.encrypted).toBe(true);
46
51
  expect(f.sensitive).toBe(true);
47
52
  });