@cosmicdrift/kumiko-framework 0.398.0 → 0.400.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 (143) hide show
  1. package/AGENTS.md +33 -19
  2. package/agents/api/dev-server-testing.md +23 -0
  3. package/agents/api/framework-db.md +277 -0
  4. package/agents/api/framework-engine.md +1810 -0
  5. package/agents/api/framework-errors.md +119 -0
  6. package/agents/api/framework-event-store.md +99 -0
  7. package/agents/api/framework-seeding.md +26 -0
  8. package/agents/api/framework-ui-types.md +542 -0
  9. package/agents/api/renderer-web.md +1450 -0
  10. package/agents/api/testing-helpers.md +189 -0
  11. package/agents/api/testing-stack.md +144 -0
  12. package/agents/api/types.md +5124 -0
  13. package/agents/api.md +20 -0
  14. package/agents/examples/derived-fields-sql.md +251 -0
  15. package/agents/examples/documents.md +161 -0
  16. package/agents/examples/inspection-checklist.md +807 -0
  17. package/agents/examples.md +3 -0
  18. package/agents/features-index.md +178 -0
  19. package/agents/features.md +9 -1
  20. package/dist/api/entity-read-access.d.ts +2 -0
  21. package/dist/api/entity-read-access.js +46 -2
  22. package/dist/api/server-boot-checks.js +2 -2
  23. package/dist/api/server-routes.js +3 -1
  24. package/dist/db/derived-field-sql.d.ts +23 -0
  25. package/dist/db/derived-field-sql.js +115 -0
  26. package/dist/db/derived-related-sql.d.ts +31 -0
  27. package/dist/db/derived-related-sql.js +137 -0
  28. package/dist/db/driver-row-coercion.d.ts +1 -0
  29. package/dist/db/driver-row-coercion.js +13 -0
  30. package/dist/db/eagerload.d.ts +3 -1
  31. package/dist/db/eagerload.js +4 -6
  32. package/dist/db/event-store-executor-context.js +1 -1
  33. package/dist/db/event-store-executor-read-paging.js +1 -1
  34. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  35. package/dist/db/event-store-executor-read-search.js +17 -9
  36. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  37. package/dist/db/event-store-executor-read-sort.js +27 -14
  38. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  39. package/dist/db/event-store-executor-read-where.js +3 -9
  40. package/dist/db/event-store-executor-read.js +23 -4
  41. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  42. package/dist/db/event-store-executor-write-steps.js +20 -1
  43. package/dist/db/event-store-executor-write.js +8 -8
  44. package/dist/db/money.d.ts +4 -0
  45. package/dist/db/money.js +34 -29
  46. package/dist/db/parent-ref-clause.d.ts +1 -0
  47. package/dist/db/parent-ref-clause.js +18 -8
  48. package/dist/db/read-gate-clause.d.ts +7 -0
  49. package/dist/db/read-gate-clause.js +188 -0
  50. package/dist/db/read-ownership.d.ts +9 -0
  51. package/dist/db/read-ownership.js +12 -0
  52. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  53. package/dist/engine/boot-validator/boot-check.js +7 -11
  54. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  55. package/dist/engine/boot-validator/boot-context.js +57 -0
  56. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  57. package/dist/engine/boot-validator/boot-options.js +1 -0
  58. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  59. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  60. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  61. package/dist/engine/boot-validator/boot-phases.js +140 -0
  62. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  63. package/dist/engine/boot-validator/boot-report.js +47 -0
  64. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  65. package/dist/engine/boot-validator/boot-rule.js +14 -0
  66. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  67. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  68. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  69. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  70. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  71. package/dist/engine/boot-validator/entity-handler.js +6 -3
  72. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  73. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  74. package/dist/engine/boot-validator/field-conditions.js +178 -0
  75. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  76. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  77. package/dist/engine/boot-validator/index.d.ts +5 -34
  78. package/dist/engine/boot-validator/index.js +24 -204
  79. package/dist/engine/boot-validator/pii-retention.js +2 -7
  80. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  81. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  82. package/dist/engine/boot-validator/read-gate.js +54 -0
  83. package/dist/engine/boot-validator/screens-forms.js +19 -6
  84. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  85. package/dist/engine/boot-validator/screens-lists.js +8 -0
  86. package/dist/engine/boot-validator/zod-shape.js +4 -2
  87. package/dist/engine/build-app-schema.js +11 -5
  88. package/dist/engine/embedded-derived.d.ts +8 -6
  89. package/dist/engine/embedded-derived.js +25 -7
  90. package/dist/engine/entity-handlers.d.ts +1 -1
  91. package/dist/engine/entity-handlers.js +17 -18
  92. package/dist/engine/entity-list-runtime-options.d.ts +5 -1
  93. package/dist/engine/entity-list-runtime-options.js +103 -4
  94. package/dist/engine/factories.d.ts +5 -5
  95. package/dist/engine/factories.js +3 -5
  96. package/dist/engine/field-access.d.ts +2 -0
  97. package/dist/engine/field-access.js +48 -1
  98. package/dist/engine/field-state.d.ts +12 -0
  99. package/dist/engine/field-state.js +67 -0
  100. package/dist/engine/handler-helpers.d.ts +3 -0
  101. package/dist/engine/handler-helpers.js +7 -1
  102. package/dist/engine/index.d.ts +2 -1
  103. package/dist/engine/index.js +2 -1
  104. package/dist/engine/ownership.d.ts +1 -1
  105. package/dist/engine/ownership.js +9 -5
  106. package/dist/engine/registry-state.d.ts +1 -0
  107. package/dist/engine/registry-state.js +1 -1
  108. package/dist/engine/schema-builder.js +3 -0
  109. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  110. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  111. package/dist/entrypoint/index.js +1 -0
  112. package/dist/entrypoint/internals.d.ts +2 -0
  113. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  114. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  115. package/dist/files/bind-pending-file-refs.js +11 -12
  116. package/dist/files/bind-referenced-files.write.js +5 -5
  117. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  118. package/dist/files/create-file-ref-from-bytes.js +2 -0
  119. package/dist/files/create-file-ref.d.ts +2 -0
  120. package/dist/files/create-file-ref.js +39 -22
  121. package/dist/files/dispose-removed-file-refs.js +5 -8
  122. package/dist/files/file-field-slots.d.ts +10 -0
  123. package/dist/files/file-field-slots.js +53 -0
  124. package/dist/files/file-ref-entity.d.ts +1 -1
  125. package/dist/files/file-ref-table.d.ts +1 -1
  126. package/dist/files/file-routes.js +30 -9
  127. package/dist/files/index.d.ts +1 -0
  128. package/dist/files/index.js +1 -0
  129. package/dist/files/load-save-file-refs.js +3 -4
  130. package/dist/files/record-read-check.js +2 -30
  131. package/dist/files/record-write-check.d.ts +2 -0
  132. package/dist/files/record-write-check.js +18 -3
  133. package/dist/files/upload-field-resolution.d.ts +4 -1
  134. package/dist/files/upload-field-resolution.js +57 -12
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-command.js +46 -12
  139. package/dist/schema-cli.js +1 -1
  140. package/dist/ui-types/index.d.ts +2 -1
  141. package/dist/ui-types/index.js +2 -1
  142. package/package.json +3 -3
  143. package/src/changes.json +67 -0
@@ -0,0 +1,54 @@
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { FILE_REF_ENTITY_NAME } from "../../files/types.js";
3
+ import { findEntity } from "./parent-ref.js";
4
+ function fail(check, message) {
5
+ throw new Error(`[Kumiko ReadGate] entity "${check.entityName}" (feature: "${check.feature.name}"): ${message}`);
6
+ }
7
+ function validateAnyLink(check, anyLink) {
8
+ const link = findEntity(check.featureMap, anyLink.entity);
9
+ if (link === undefined) {
10
+ fail(check, `readGate.anyLink.entity "${anyLink.entity}" is not registered.`);
11
+ }
12
+ if (link.parentRef === undefined) {
13
+ fail(check, `readGate.anyLink.entity "${anyLink.entity}" must declare a parentRef; its host visibility is what the gate checks.`);
14
+ }
15
+ if (link.fields[anyLink.field] === undefined) {
16
+ fail(check, `readGate.anyLink.field "${anyLink.field}" is not a field of "${anyLink.entity}".`);
17
+ }
18
+ }
19
+ function validateFileOwner(check, fileOwner) {
20
+ const fileField = check.entity.fields[fileOwner.field];
21
+ if (fileField?.type !== "file" && fileField?.type !== "image") {
22
+ fail(check, `readGate.fileOwner.field "${fileOwner.field}" must be a single file or image field.`);
23
+ }
24
+ if (findEntity(check.featureMap, FILE_REF_ENTITY_NAME) === undefined) {
25
+ fail(check, `readGate.fileOwner needs the files feature (entity "${FILE_REF_ENTITY_NAME}").`);
26
+ }
27
+ }
28
+ function validateNoHandWrittenReaders(check) {
29
+ for (const verb of ["list", "detail"]) {
30
+ const handlerName = `${check.entityName}:${verb}`;
31
+ for (const f of check.featureMap.values()) {
32
+ const handler = f.queryHandlers[handlerName];
33
+ if (handler !== undefined && handler[ENTITY_CONVENTION_QUERY_BRAND] !== true) {
34
+ fail(check, `query handler "${handlerName}" is hand-written and would bypass the readGate. Fix: register it via defineEntityListHandler/defineEntityDetailHandler.`);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ export function validateReadGates(feature, featureMap) {
40
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
41
+ const gate = entity.readGate;
42
+ if (gate === undefined)
43
+ continue;
44
+ const check = { feature, featureMap, entityName, entity };
45
+ if (entity.parentRef !== undefined) {
46
+ fail(check, "readGate and parentRef cannot be combined; a gated entity cannot be a join row.");
47
+ }
48
+ if (gate.anyLink !== undefined)
49
+ validateAnyLink(check, gate.anyLink);
50
+ if (gate.fileOwner !== undefined)
51
+ validateFileOwner(check, gate.fileOwner);
52
+ validateNoHandWrittenReaders(check);
53
+ }
54
+ }
@@ -3,6 +3,22 @@ import { isKebabSegment } from "../qualified-name.js";
3
3
  import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, sectionFieldSpecs, } from "../screen-helpers.js";
4
4
  import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
5
5
  import { assertWriteHandlerRegistered, isSensitiveOrPasswordField, validateIdlessSuccessLink, validateRedirectTarget, validateScreenNavTarget, } from "./screens-actions.js";
6
+ function assertKnownSubFields(where, option, cells, schema) {
7
+ for (const cell of cells ?? []) {
8
+ if (schema[cell] === undefined) {
9
+ throw new Error(`${where} embeddedList.${option} names "${cell}", which is not a sub-field ` +
10
+ `(known: ${Object.keys(schema).sort().join(", ")}).`);
11
+ }
12
+ }
13
+ }
14
+ function assertCardHiddenCellsReadOnly(where, cardHiddenCells, readOnlyCells) {
15
+ for (const cell of cardHiddenCells ?? []) {
16
+ if (readOnlyCells?.includes(cell) !== true) {
17
+ throw new Error(`${where} embeddedList.cardHiddenCells names "${cell}", which is editable; ` +
18
+ `only read-only cells (readOnlyCells) can be hidden on cards, otherwise phone users cannot fill it.`);
19
+ }
20
+ }
21
+ }
6
22
  // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
7
23
  // entityEdit path — a required field the user can never fill would
8
24
  // block every save. Only the statically-resolvable case is caught here: a
@@ -23,12 +39,9 @@ export function validateEditFieldExtras(featureName, screenId, entityDef, fieldS
23
39
  throw new Error(`${where} sets embeddedList, which only applies to an embedded list ` +
24
40
  `(createEmbeddedListField); the field is type "${fieldDef.type}". Remove embeddedList or point it at an embedded list field.`);
25
41
  }
26
- for (const cell of listSpec.readOnlyCells ?? []) {
27
- if (fieldDef.schema[cell] === undefined) {
28
- throw new Error(`${where} embeddedList.readOnlyCells names "${cell}", which is not a sub-field ` +
29
- `(known: ${Object.keys(fieldDef.schema).sort().join(", ")}).`);
30
- }
31
- }
42
+ assertKnownSubFields(where, "readOnlyCells", listSpec.readOnlyCells, fieldDef.schema);
43
+ assertKnownSubFields(where, "cardHiddenCells", listSpec.cardHiddenCells, fieldDef.schema);
44
+ assertCardHiddenCellsReadOnly(where, listSpec.cardHiddenCells, listSpec.readOnlyCells);
32
45
  }
33
46
  const prefill = fieldSpec.prefill;
34
47
  // skip: no prefill declared
@@ -3,6 +3,7 @@ import { rowMetaFieldNames } from "../../db/table-builder.js";
3
3
  import { qualifyEntityName } from "../qualified-name.js";
4
4
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
5
5
  import { normalizeListColumn } from "../screen-helpers.js";
6
+ import { isSortableSqlDerivedField } from "./derived-sql-fields.js";
6
7
  import { validateEntityListAggregateOptions } from "./entity-list-aggregate-options.js";
7
8
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
8
9
  import { validateListBulkActions } from "./list-bulk-actions.js";
@@ -14,10 +15,9 @@ import { validateRelatedListSection, validateRowActionTarget } from "./screens-l
14
15
  function resolveEntityListFields(ctx, screenId, entityName) {
15
16
  const entityDef = resolveScreenEntity(ctx, screenId, entityName);
16
17
  const fieldNames = new Set(Object.keys(entityDef.fields));
17
- // List columns may also name a read-time derived field (not a stored
18
- // column). Allowed for display; deliberately NOT added to `fieldNames`, so
19
- // defaultSort/filter on a derived field still fails — server-side sort over
20
- // a non-column is a silent no-op (see DerivedFieldDef).
18
+ // List columns may also name a read-time derived field (not a stored column).
19
+ // Deliberately NOT added to `fieldNames`, so filtering one still fails; only a
20
+ // `sortable` SQL derived field may be the defaultSort (see isSortableSqlDerivedField).
21
21
  const columnFieldNames = entityDef.derivedFields !== undefined
22
22
  ? new Set([...fieldNames, ...Object.keys(entityDef.derivedFields)])
23
23
  : fieldNames;
@@ -52,7 +52,8 @@ function validateEntityListColumns(ctx, screenId, screen, fields) {
52
52
  }
53
53
  function validateEntityListDefaultSort(featureName, screenId, defaultSort, fields) {
54
54
  const sortField = defaultSort.field;
55
- if (!fields.fieldNames.has(sortField)) {
55
+ const sortsOnDerived = isSortableSqlDerivedField(fields.entityDef, sortField);
56
+ if (!fields.fieldNames.has(sortField) && !sortsOnDerived) {
56
57
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (entityList) defaultSort references unknown ` +
57
58
  `field "${sortField}". Known fields: ${[...fields.fieldNames].sort().join(", ")}`);
58
59
  }
@@ -64,7 +65,8 @@ function validateEntityListDefaultSort(featureName, screenId, defaultSort, field
64
65
  // Number/Date fields are naturally sortable. Authors can mark them
65
66
  // sortable when their field type supports it.
66
67
  const fieldDef = fields.entityDef.fields[sortField];
67
- const isSortable = fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
68
+ const isSortable = sortsOnDerived ||
69
+ (fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true);
68
70
  if (!isSortable) {
69
71
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (entityList) defaultSort.field "${sortField}" ` +
70
72
  `is not sortable. Set sortable: true on the field definition or pick another field.`);
@@ -84,11 +84,19 @@ function validateRelatedListTableAndBulk(args) {
84
84
  validateRelatedListRowActions(args, section.rowActions);
85
85
  validateListBulkActions(`[Feature ${featureName}] ${where}`, section.bulkActions, section.rowActions);
86
86
  }
87
+ function assertReadOnlyHasNoRowInteraction(args) {
88
+ const { section } = args;
89
+ const interactive = section.rowClick !== undefined || (section.rowActions?.length ?? 0) > 0;
90
+ if (section.readOnly === true && interactive) {
91
+ throw new Error(`[Feature ${args.featureName}] ${args.where} is readOnly but declares rowClick/rowActions. Remove readOnly or the row interaction.`);
92
+ }
93
+ }
87
94
  export function validateRelatedListSection(args) {
88
95
  const { featureName, featureMap, where, section } = args;
89
96
  if (!section.query || typeof section.query !== "string") {
90
97
  throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query. Set query to a registered query name.`);
91
98
  }
99
+ assertReadOnlyHasNoRowInteraction(args);
92
100
  if (section.entity !== undefined) {
93
101
  assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
94
102
  }
@@ -1,4 +1,4 @@
1
- import { ZodArray, ZodCatch, ZodDefault, ZodIntersection, ZodLazy, ZodMap, ZodNonOptional, ZodNullable, ZodObject, ZodOptional, ZodPipe, ZodPrefault, ZodPromise, ZodReadonly, ZodRecord, ZodSet, ZodTuple, ZodUnion, ZodXor, } from "zod";
1
+ import { ZodArray, ZodCatch, ZodDefault, ZodIntersection, ZodLazy, ZodMap, ZodNever, ZodNonOptional, ZodNullable, ZodObject, ZodOptional, ZodPipe, ZodPrefault, ZodPromise, ZodReadonly, ZodRecord, ZodSet, ZodTuple, ZodUnion, ZodXor, } from "zod";
2
2
  // Drills through wrapper types (.nullable(), .optional(), .default()) a
3
3
  // handler's schema may use around its actual object/array shape — e.g. a
4
4
  // detail query returning `Row | null` when the record doesn't exist.
@@ -29,7 +29,9 @@ export function getZodObjectShape(schema) {
29
29
  const unwrapped = unwrapZodType(schema);
30
30
  // loose()/catchall() objects accept keys beyond the declared shape, so the
31
31
  // declared keys are not the full set callers may legitimately reference.
32
- const hasOpenKeys = unwrapped instanceof ZodObject && unwrapped.def.catchall !== undefined;
32
+ // .strict() also sets a catchall, ZodNever, which closes the keys instead.
33
+ const catchall = unwrapped instanceof ZodObject ? unwrapped.def.catchall : undefined;
34
+ const hasOpenKeys = catchall !== undefined && !(catchall instanceof ZodNever);
33
35
  return unwrapped instanceof ZodObject && !hasOpenKeys ? unwrapped.shape : undefined;
34
36
  }
35
37
  // The shape of one row for a query whose result follows the paged-list
@@ -25,6 +25,7 @@
25
25
  // Feature toggles: this schema is tenant-independent and lists every feature.
26
26
  // The schema route strips features the caller's tenant has switched off
27
27
  // (r.toggleable) via stripDisabledFeaturesFromAppSchema.
28
+ import { isSqlDerivedField } from "@cosmicdrift/kumiko-types/fields";
28
29
  import { ZodObject } from "zod";
29
30
  import { createFallbackLogger } from "../logging/utils.js";
30
31
  import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields.js";
@@ -426,12 +427,13 @@ function projectEntity(entityName, entity) {
426
427
  ...(entity.lockedWhen !== undefined && { lockedWhen: entity.lockedWhen }),
427
428
  };
428
429
  }
429
- // Only valueType is forwarded — the derive function is server-only and is not
430
- // JSON-safe (it would otherwise trigger the output-walk guard). Return
431
- // ClientDerivedFieldDef honestly instead of pretending to return a
432
- // DerivedFieldDef without its derive function.
430
+ // derive/sql are server-only and not JSON-safe (they would trip the output-walk guard).
433
431
  function projectDerivedField(derivedDef) {
434
- return { valueType: derivedDef.valueType };
432
+ return {
433
+ valueType: derivedDef.valueType,
434
+ ...(isSqlDerivedField(derivedDef) &&
435
+ derivedDef.sortable === true && { sortable: true }),
436
+ };
435
437
  }
436
438
  // A textarea height that is not a positive integer cannot render anywhere; fail
437
439
  // at boot with the field name instead of letting each renderer guess a fallback.
@@ -491,6 +493,9 @@ const FORWARDED_FIELD_PROPERTIES = [
491
493
  ["optionsAvailabilityQuery", isStringValue],
492
494
  // Select: options only offered while a sibling-field condition holds; the renderer filters, the write path enforces.
493
495
  ["conditionalOptions", Array.isArray],
496
+ // Conditional field state: the renderer evaluates it per row/form, the write path enforces requiredWhen.
497
+ ["visibleWhen", isJsonSafeObject],
498
+ ["requiredWhen", isJsonSafeObject],
494
499
  ["multiple", isBooleanValue],
495
500
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
496
501
  // columns/maxRows size the checkbox grid — without these the renderer
@@ -527,6 +532,7 @@ const FORWARDED_FIELD_PROPERTIES = [
527
532
  // the preview variant.
528
533
  ["accept", isJsonSafeArray],
529
534
  ["maxSize", isStringValue],
535
+ ["maxCount", isNumberValue],
530
536
  ["variants", isJsonSafeObject],
531
537
  // decimal, incl. embedded sub-fields: rounding of derived embedded-list cells.
532
538
  ["scale", isNumberValue],
@@ -1,11 +1,12 @@
1
1
  import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "@cosmicdrift/kumiko-types/fields";
2
+ export type NumericDerivedOp = Exclude<EmbeddedDerivedCellDef["op"], "inRange">;
2
3
  /** Computes a derived cell from its source values. Missing/non-numeric
3
4
  * sources: "multiply"/"percent" with any missing source → undefined; "sum"/"subtract"
4
5
  * treat missing as 0 only when at least one source is present — if every
5
6
  * source is missing, return undefined (do not invent 0). Money cells are
6
7
  * minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
7
8
  * to the target sub-field's declared precision afterward. */
8
- export declare function computeDerivedCellValue(op: EmbeddedDerivedCellDef["op"], values: readonly (number | undefined)[]): number | undefined;
9
+ export declare function computeDerivedCellValue(op: NumericDerivedOp, values: readonly (number | undefined)[]): number | undefined;
9
10
  export type DerivedCellRoundingTarget = {
10
11
  readonly type: EmbeddedSubFieldDef["type"];
11
12
  readonly scale?: number;
@@ -19,11 +20,12 @@ export declare function roundDerivedCellValue(value: number, target: DerivedCell
19
20
  /** Recomputes every derived cell of an embedded-list row from its raw
20
21
  * values, overwriting whatever the client sent instead of merely checking
21
22
  * it — the server is the authority for derived cells. Reads source values
22
- * from the working copy so already-recomputed sources feed later cells;
23
- * boot-validator rejects derived-from-derived chains. A row that isn't a
23
+ * from the working copy, so a cell declared after its source sees the
24
+ * recomputed source. A row that isn't a
24
25
  * plain object (already invalid, or not this field's shape) passes through
25
26
  * untouched — validation downstream rejects it. The computed value is
26
- * rounded to the target sub-field's declared precision (`schema`) before
27
+ * rounded to the target sub-field's declared precision (`targets`) before
27
28
  * it's written back, so a fractional product lands on a value the target
28
- * type can actually represent. */
29
- export declare function withDerivedCells(row: unknown, derived: Readonly<Record<string, EmbeddedDerivedCellDef>>, schema: Readonly<Record<string, EmbeddedSubFieldDef>>): unknown;
29
+ * type can actually represent. A cell whose `when` is false for the row is
30
+ * not derived there: its row value is left as sent. */
31
+ export declare function withDerivedCells(row: unknown, derived: Readonly<Record<string, EmbeddedDerivedCellDef>>, targets: Readonly<Record<string, DerivedCellRoundingTarget>>): unknown;
@@ -1,4 +1,5 @@
1
1
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { evalFieldCondition } from "./screen-helpers.js";
2
3
  /** Computes a derived cell from its source values. Missing/non-numeric
3
4
  * sources: "multiply"/"percent" with any missing source → undefined; "sum"/"subtract"
4
5
  * treat missing as 0 only when at least one source is present — if every
@@ -58,33 +59,50 @@ function roundHalfAwayFromZero(value, decimals) {
58
59
  const scaled = Number((Math.abs(value) * factor).toPrecision(15));
59
60
  return (Math.sign(value) * Math.round(scaled)) / factor;
60
61
  }
62
+ /** `from` is `[value, min, max]`; bounds are inclusive and either may be missing. */
63
+ function computeInRangeCell(values, inside, outside) {
64
+ const [value, min, max] = values;
65
+ if (value === undefined || (min === undefined && max === undefined))
66
+ return undefined;
67
+ const aboveMin = min === undefined || value >= min;
68
+ const belowMax = max === undefined || value <= max;
69
+ return aboveMin && belowMax ? inside : outside;
70
+ }
61
71
  /** Recomputes every derived cell of an embedded-list row from its raw
62
72
  * values, overwriting whatever the client sent instead of merely checking
63
73
  * it — the server is the authority for derived cells. Reads source values
64
- * from the working copy so already-recomputed sources feed later cells;
65
- * boot-validator rejects derived-from-derived chains. A row that isn't a
74
+ * from the working copy, so a cell declared after its source sees the
75
+ * recomputed source. A row that isn't a
66
76
  * plain object (already invalid, or not this field's shape) passes through
67
77
  * untouched — validation downstream rejects it. The computed value is
68
- * rounded to the target sub-field's declared precision (`schema`) before
78
+ * rounded to the target sub-field's declared precision (`targets`) before
69
79
  * it's written back, so a fractional product lands on a value the target
70
- * type can actually represent. */
71
- export function withDerivedCells(row, derived, schema) {
80
+ * type can actually represent. A cell whose `when` is false for the row is
81
+ * not derived there: its row value is left as sent. */
82
+ export function withDerivedCells(row, derived, targets) {
72
83
  if (!isPlainObject(row))
73
84
  return row;
74
85
  const copy = { ...row };
75
86
  for (const [cellName, def] of Object.entries(derived)) {
87
+ if (def.when !== undefined && !evalFieldCondition(def.when, row))
88
+ continue;
76
89
  const sourceValues = def.from.map((sourceField) => {
77
90
  const value = copy[sourceField];
78
91
  return typeof value === "number" ? value : undefined;
79
92
  });
80
- const computed = computeDerivedCellValue(def.op, sourceValues);
93
+ const computed = def.op === "inRange"
94
+ ? computeInRangeCell(sourceValues, def.inside, def.outside)
95
+ : computeDerivedCellValue(def.op, sourceValues);
81
96
  if (computed === undefined) {
82
97
  // Always clear — keeping a client value would let callers spoof required
83
98
  // derived cells when sources are missing (server must stay authoritative).
84
99
  delete copy[cellName];
85
100
  }
101
+ else if (typeof computed === "string") {
102
+ copy[cellName] = computed;
103
+ }
86
104
  else {
87
- const target = schema[cellName];
105
+ const target = targets[cellName];
88
106
  copy[cellName] = target === undefined ? computed : roundDerivedCellValue(computed, target);
89
107
  }
90
108
  }
@@ -1,4 +1,4 @@
1
- import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
1
+ import { type EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
2
  import type { AccessRule, EscapeHatchDeclaration, QueryHandlerDef, WriteHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
3
3
  import * as z from "zod";
4
4
  import { type EventStoreExecutor } from "../db/event-store-executor.ts";
@@ -1,3 +1,4 @@
1
+ import { isSqlDerivedField, } from "@cosmicdrift/kumiko-types/fields";
1
2
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import { listAggregatePayloadSchema, } from "@cosmicdrift/kumiko-types/list-aggregate-types";
3
4
  import * as z from "zod";
@@ -10,7 +11,7 @@ import { InternalError } from "../errors/index.js";
10
11
  import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
11
12
  import { assertUnreachable } from "../utils/index.js";
12
13
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
13
- import { entityListRuntime } from "./entity-list-runtime-options.js";
14
+ import { entityListRuntime, parentVisibilityFor } from "./entity-list-runtime-options.js";
14
15
  import { assertReason } from "./escape-hatch-reason.js";
15
16
  import { dropEmptyWriteOnlyValues } from "./field-access.js";
16
17
  import { instructionFieldNames } from "./instruction-fields.js";
@@ -289,7 +290,7 @@ export function defineEntityWriteHandler(name, entity, options) {
289
290
  // they rely on to intentionally force a fresh event/ciphertext.
290
291
  return executor.update(payload, event.user, db, {
291
292
  streamTenantId,
292
- parentVisibility: { entities: ctx.registry.getAllEntities() },
293
+ parentVisibility: parentVisibilityFor(ctx, event.user),
293
294
  skipUnchanged: true,
294
295
  preSave: runPreSave &&
295
296
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -304,7 +305,7 @@ export function defineEntityWriteHandler(name, entity, options) {
304
305
  const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
305
306
  return executor.delete(payload, event.user, db, {
306
307
  streamTenantId,
307
- parentVisibility: { entities: ctx.registry.getAllEntities() },
308
+ parentVisibility: parentVisibilityFor(ctx, event.user),
308
309
  });
309
310
  };
310
311
  break;
@@ -316,7 +317,7 @@ export function defineEntityWriteHandler(name, entity, options) {
316
317
  const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
317
318
  return executor.restore(payload, event.user, db, {
318
319
  streamTenantId,
319
- parentVisibility: { entities: ctx.registry.getAllEntities() },
320
+ parentVisibility: parentVisibilityFor(ctx, event.user),
320
321
  });
321
322
  };
322
323
  break;
@@ -334,23 +335,21 @@ export function defineEntityWriteHandler(name, entity, options) {
334
335
  ...(excludedFields.length > 0 && { excludedFields }),
335
336
  };
336
337
  }
337
- // Append read-time derived-field values (EntityDefinition.derivedFields) to
338
- // each row, after the SQL fetch + reference eagerload — i.e. on the row shape
339
- // the view-model will see. The clock is the read instant; derive bodies must
340
- // read it from ctx.asOf, never their own Date/Temporal.Now. No-op (returns the
341
- // rows untouched) when the entity declares no derived fields.
342
- function augmentDerivedFields(rows, entity) {
338
+ // Runs the JS `derive` fields on the row shape the view-model will see (after the SQL fetch,
339
+ // reference eagerload and SQL derived values), so a JS derive may read a SQL derived value.
340
+ // Clock comes from the handler's tz context, never Temporal.Now.
341
+ function augmentDerivedFields(rows, entity, asOf) {
343
342
  const derived = entity.derivedFields;
344
343
  if (derived === undefined)
345
344
  return rows;
346
- const entries = Object.entries(derived);
347
- if (entries.length === 0)
345
+ const jsEntries = Object.entries(derived).flatMap(([fieldName, def]) => isSqlDerivedField(def) ? [] : [[fieldName, def.derive]]);
346
+ if (jsEntries.length === 0)
348
347
  return rows;
349
- const ctx = { asOf: Temporal.Now.instant() };
348
+ const ctx = { asOf };
350
349
  return rows.map((row) => {
351
350
  const out = { ...row };
352
- for (const [fieldName, def] of entries) {
353
- out[fieldName] = def.derive(row, ctx);
351
+ for (const [fieldName, derive] of jsEntries) {
352
+ out[fieldName] = derive(row, ctx);
354
353
  }
355
354
  return out;
356
355
  });
@@ -409,7 +408,7 @@ export function defineEntityQueryHandler(name, entity, options) {
409
408
  const enrichedRows = hasRefFields
410
409
  ? await enrichWithReferences(result.rows, entity, resolveEntity, db, eagerLoadViewer)
411
410
  : result.rows;
412
- return { ...result, rows: augmentDerivedFields(enrichedRows, entity) };
411
+ return { ...result, rows: augmentDerivedFields(enrichedRows, entity, ctx.tz.now()) };
413
412
  };
414
413
  break;
415
414
  case "detail":
@@ -417,13 +416,13 @@ export function defineEntityQueryHandler(name, entity, options) {
417
416
  handler = async (query, ctx) => {
418
417
  const db = dbFor(ctx);
419
418
  const row = await executor.detail(query.payload, query.user, db, {
420
- parentVisibility: { entities: ctx.registry.getAllEntities() },
419
+ parentVisibility: parentVisibilityFor(ctx, query.user),
421
420
  }); // @cast-boundary engine-payload
422
421
  if (row === null || !hasRefFields)
423
422
  return row;
424
423
  return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db, {
425
424
  user: query.user,
426
- parentVisibility: { entities: ctx.registry.getAllEntities() },
425
+ parentVisibility: parentVisibilityFor(ctx, query.user),
427
426
  });
428
427
  };
429
428
  break;
@@ -1,8 +1,12 @@
1
- import type { ListRuntimeOptions } from "@cosmicdrift/kumiko-types/event-store-executor-types";
1
+ import type { AggregateKey } from "@cosmicdrift/kumiko-types/aggregate-types";
2
+ import type { ListRuntimeOptions, ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
2
3
  import type { EagerLoadEntityResolver, EagerLoadViewer } from "../db/eagerload.ts";
4
+ import type { TenantDb } from "../db/tenant-db.ts";
3
5
  import type { HandlerContext, SessionUser } from "./types/index.ts";
6
+ export declare function parentVisibilityFor(ctx: HandlerContext, user: SessionUser): ParentVisibilityOption;
4
7
  export declare function entityListRuntime(ctx: HandlerContext, entityName: string, user: SessionUser): {
5
8
  readonly runtimeOptions: ListRuntimeOptions;
6
9
  readonly eagerLoadViewer: EagerLoadViewer;
7
10
  readonly resolveEntity: EagerLoadEntityResolver;
8
11
  };
12
+ export declare function referenceGroupLabels(ctx: HandlerContext, user: SessionUser, sourceTableName: string, groupByField: string, keys: readonly AggregateKey[], db: TenantDb): Promise<ReadonlyMap<string, string>>;
@@ -1,20 +1,119 @@
1
+ import { collectEntityDetailAccess, collectEntityReadAccess } from "../api/entity-read-access.js";
2
+ import { resolveTableName } from "../db/entity-table-meta.js";
3
+ import { labelListAggregateGroups } from "../db/list-aggregate-labels.js";
4
+ import { buildEntityTable } from "../db/table-builder.js";
5
+ import { extractTableInfo } from "../db/table-info.js";
6
+ import { hasAccess } from "./access.js";
7
+ import { IMPLICIT_PROJECTION_SUFFIX } from "./registry-state.js";
8
+ // The rules only change with the registry, so they are collected once, not per request.
9
+ const readAccessByRegistry = new WeakMap();
10
+ const detailAccessByRegistry = new WeakMap();
11
+ function detailAccessOf(registry) {
12
+ let rules = detailAccessByRegistry.get(registry);
13
+ if (rules === undefined) {
14
+ rules = collectEntityDetailAccess(registry);
15
+ detailAccessByRegistry.set(registry, rules);
16
+ }
17
+ return rules;
18
+ }
19
+ function readAccessOf(registry) {
20
+ let rules = readAccessByRegistry.get(registry);
21
+ if (rules === undefined) {
22
+ rules = collectEntityReadAccess(registry);
23
+ readAccessByRegistry.set(registry, rules);
24
+ }
25
+ return rules;
26
+ }
27
+ // An entity with a backingTable is read from that table; the implicit projection holds it.
28
+ function readTableOf(ctx, entityName, entity) {
29
+ const implicit = ctx.registry
30
+ .getProjectionsForSource(entityName)
31
+ .find((projection) => projection.name.endsWith(`:projection:${entityName}${IMPLICIT_PROJECTION_SUFFIX}`));
32
+ return implicit?.table ?? buildEntityTable(entityName, entity);
33
+ }
34
+ // The join-row and read gates resolve hosts and file owners against the registry, which only
35
+ // exists at request time; the handler check is the one live signals use too.
36
+ export function parentVisibilityFor(ctx, user) {
37
+ const detailRules = detailAccessOf(ctx.registry);
38
+ return {
39
+ entities: ctx.registry.getAllEntities(),
40
+ canReadEntity: (name) => {
41
+ const rule = detailRules.get(name);
42
+ return rule !== undefined && hasAccess(user, rule);
43
+ },
44
+ resolveTable: (name) => {
45
+ const entity = ctx.registry.getEntity(name);
46
+ if (entity === undefined)
47
+ throw new Error(`parentVisibility: unknown entity "${name}"`);
48
+ return readTableOf(ctx, name, entity);
49
+ },
50
+ };
51
+ }
1
52
  // Registry access only exists at request time, so the searchable/sortable-reference
2
53
  // caches, the entity resolver and the join-row read gate travel through the
3
54
  // runtime-override extension point of the executor verbs.
4
55
  export function entityListRuntime(ctx, entityName, user) {
5
- const parentVisibility = { entities: ctx.registry.getAllEntities() };
56
+ const parentVisibility = parentVisibilityFor(ctx, user);
6
57
  const resolveEntity = (name) => ctx.registry.getEntity(name);
58
+ const resolveTable = (name) => {
59
+ const entity = ctx.registry.getEntity(name);
60
+ if (entity === undefined)
61
+ throw new Error(`entityListRuntime: unknown entity "${name}"`);
62
+ return readTableOf(ctx, name, entity);
63
+ };
64
+ const readRules = readAccessOf(ctx.registry);
7
65
  return {
8
66
  runtimeOptions: {
9
67
  ...(ctx.services.searchAdapter !== undefined && {
10
68
  searchAdapter: ctx.services.searchAdapter,
11
69
  }),
12
70
  ...(ctx.includeDeleted === true && { includeDeleted: true }),
13
- referenceSearch: { fields: ctx.registry.getSearchableReferences(entityName), resolveEntity },
14
- referenceSort: { fields: ctx.registry.getSortableReferences(entityName), resolveEntity },
71
+ referenceSearch: {
72
+ fields: ctx.registry.getSearchableReferences(entityName),
73
+ resolveEntity,
74
+ resolveTable,
75
+ },
76
+ referenceSort: {
77
+ fields: ctx.registry.getSortableReferences(entityName),
78
+ resolveEntity,
79
+ resolveTable,
80
+ },
15
81
  parentVisibility,
82
+ derivedSql: {
83
+ resolveEntity,
84
+ resolveTable,
85
+ canReadEntity: (name) => (readRules.get(name) ?? []).some((rule) => hasAccess(user, rule)),
86
+ today: ctx.tz.today(ctx.tz.tenant).toString(),
87
+ now: ctx.tz.now().toString(),
88
+ },
16
89
  },
17
- eagerLoadViewer: { user, parentVisibility },
90
+ eagerLoadViewer: { user, parentVisibility, resolveTable },
18
91
  resolveEntity,
19
92
  };
20
93
  }
94
+ // An entity with a backingTable reads and writes that table, whose name the entity's own
95
+ // `table` does not say; the implicit projection holds the table the executor really uses.
96
+ function physicalReadTableName(ctx, entityName, entity) {
97
+ const implicit = ctx.registry
98
+ .getProjectionsForSource(entityName)
99
+ .find((projection) => projection.name.endsWith(`:projection:${entityName}${IMPLICIT_PROJECTION_SUFFIX}`));
100
+ return implicit !== undefined
101
+ ? extractTableInfo(implicit.table).name
102
+ : resolveTableName(entityName, entity, undefined);
103
+ }
104
+ // Resolved through the viewer's read access, like list-aggregate labels; non-entity sources yield none.
105
+ export async function referenceGroupLabels(ctx, user, sourceTableName, groupByField, keys, db) {
106
+ const labels = new Map();
107
+ const owner = [...ctx.registry.getAllEntities()].find(([entityName, entity]) => physicalReadTableName(ctx, entityName, entity) === sourceTableName);
108
+ if (owner === undefined || keys.length === 0)
109
+ return labels;
110
+ const [entityName, entity] = owner;
111
+ const { eagerLoadViewer, resolveEntity } = entityListRuntime(ctx, entityName, user);
112
+ const labeled = await labelListAggregateGroups({ groups: keys.map((key) => ({ key, count: 0, measures: [] })) }, groupByField, { entity, resolveEntity, viewer: eagerLoadViewer }, db);
113
+ for (const group of labeled.groups) {
114
+ if (group.key !== null && group.label != null && group.label !== "") {
115
+ labels.set(String(group.key), group.label);
116
+ }
117
+ }
118
+ return labels;
119
+ }
@@ -17,11 +17,9 @@ export declare function createTextField<R extends true | false = false>(override
17
17
  * (for example, `1_000_000 = 1 MB`).
18
18
  */
19
19
  /**
20
- * Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`).
21
- * The value is derived per row from the stored columns + the clock at query
22
- * time, never persisted. Display only — server-side sort/filter/search don't
23
- * apply and there's no client-side sort path (see DerivedFieldDef). `derive`
24
- * must take its clock from `ctx.asOf`, never `Temporal.Now`/`Date`.
20
+ * Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`), never persisted.
21
+ * `derive` (display only, clock from `ctx.asOf`) or `sql` (computed in the list SELECT, optionally
22
+ * `sortable`) — see DerivedFieldDef.
25
23
  */
26
24
  export declare function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef;
27
25
  export declare function createLongTextField<R extends true | false = false>(overrides: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsLongText & {
@@ -194,6 +192,8 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
194
192
  readonly access?: EntityDefinition["access"];
195
193
  /** Join-row carrier reference — see EntityDefinition.parentRef. */
196
194
  readonly parentRef?: EntityDefinition["parentRef"];
195
+ /** See EntityDefinition.readGate. */
196
+ readonly readGate?: EntityDefinition["readGate"];
197
197
  readonly retention?: RetentionDef;
198
198
  readonly derivedFields?: EntityDefinition["derivedFields"];
199
199
  readonly tenancy?: T;
@@ -137,11 +137,9 @@ export function createTextField(overrides) {
137
137
  * (for example, `1_000_000 = 1 MB`).
138
138
  */
139
139
  /**
140
- * Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`).
141
- * The value is derived per row from the stored columns + the clock at query
142
- * time, never persisted. Display only — server-side sort/filter/search don't
143
- * apply and there's no client-side sort path (see DerivedFieldDef). `derive`
144
- * must take its clock from `ctx.asOf`, never `Temporal.Now`/`Date`.
140
+ * Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`), never persisted.
141
+ * `derive` (display only, clock from `ctx.asOf`) or `sql` (computed in the list SELECT, optionally
142
+ * `sortable`) — see DerivedFieldDef.
145
143
  */
146
144
  export function createDerivedField(spec) {
147
145
  return { ...spec };
@@ -1,4 +1,5 @@
1
1
  import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
+ import { type OwnershipMap } from "./ownership.ts";
2
3
  import type { SessionUser } from "./types/index.ts";
3
4
  export declare function maskWriteOnlyFields<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, row: T): T;
4
5
  export declare function dropEmptyWriteOnlyValues<T extends Readonly<Record<string, unknown>>>(entity: EntityDefinition, values: T): T;
@@ -6,3 +7,4 @@ export declare function userMayReadFieldInBulk(entity: EntityDefinition, fieldNa
6
7
  export declare function filterReadFields(entity: EntityDefinition, data: Readonly<Record<string, unknown>>, user: SessionUser): Record<string, unknown>;
7
8
  export declare function checkWriteFieldRoles(entity: EntityDefinition, changes: Readonly<Record<string, unknown>>, user: SessionUser): string | null;
8
9
  export declare function checkWriteFieldOwnership(entity: EntityDefinition, submittedChanges: Readonly<Record<string, unknown>>, user: SessionUser, oldRow?: Readonly<Record<string, unknown>>, rowContext?: Readonly<Record<string, unknown>>): string | null;
10
+ export declare function userHasWriteRole(user: Pick<SessionUser, "roles">, accessMap: OwnershipMap): boolean;