@cosmicdrift/kumiko-framework 0.238.0 → 0.240.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.
@@ -1,6 +1,6 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
2
  import { resolveName } from "./handler-helpers";
3
- import type { RegistryState } from "./registry-state";
3
+ import type { RegistryState, SearchableReferenceField } from "./registry-state";
4
4
  import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
5
5
  import {
6
6
  buildSoftDeleteCleanupJob,
@@ -10,7 +10,7 @@ import {
10
10
  SOFT_DELETE_GRACE_DAYS_KEY,
11
11
  softDeleteGraceDaysConfig,
12
12
  } from "./soft-delete-cleanup";
13
- import type { EventPiiFields, EventUpcastFn, FeatureDefinition } from "./types";
13
+ import type { EventPiiFields, EventUpcastFn, FeatureDefinition, ReferenceFieldDef } from "./types";
14
14
  import { HookPhases } from "./types";
15
15
 
16
16
  function allHandlerQns(state: RegistryState): ReadonlySet<string> {
@@ -154,13 +154,50 @@ export function applyExtensionUsages(state: RegistryState): void {
154
154
  }
155
155
  }
156
156
 
157
- // Precompute: searchable/sortable fields.
158
- export function buildSearchableSortableCaches(state: RegistryState): void {
159
- // Precompute: searchable/sortable fields, search includes, incoming relations
157
+ // "customer" (same-feature) or "users:customer" (cross-feature,
158
+ // "<featureName>:<entityName>") — only the entity name matters here, names
159
+ // are globally unique in entityMap. Local duplicate of the same parse done
160
+ // in db/eagerload.ts's parseRefEntity — this loop has no feature context to
161
+ // hand to engine/parse-ref-target.ts's parseRefTarget.
162
+ function parseReferenceTargetEntityName(raw: string): string {
163
+ const idx = raw.indexOf(":");
164
+ return idx < 0 ? raw : raw.slice(idx + 1);
165
+ }
166
+
167
+ // fw#2660: labelField defaults to "id" (a UUID column) elsewhere, but a
168
+ // searchable reference ILIKEs the label column — an implicit or explicit
169
+ // "id" default would 500 on every search request. Fail at boot.
170
+ function buildSearchableReferenceField(
171
+ entityName: string,
172
+ fieldName: string,
173
+ field: ReferenceFieldDef,
174
+ ): SearchableReferenceField {
175
+ if (field.labelField === undefined || field.labelField === "id") {
176
+ throw new Error(
177
+ `[Entity ${entityName}] field "${fieldName}": searchable reference fields require an ` +
178
+ `explicit, non-"id" labelField. "id" is a UUID column — ILIKE against it fails ` +
179
+ `at request time. Set labelField to a human-readable field on the referenced entity.`,
180
+ );
181
+ }
182
+ if (field.multiple === true) {
183
+ throw new Error(
184
+ `[Entity ${entityName}] field "${fieldName}": searchable is not supported on multiple ` +
185
+ `(array) reference fields — remove "multiple" or "searchable".`,
186
+ );
187
+ }
188
+ return {
189
+ fieldName,
190
+ targetEntityName: parseReferenceTargetEntityName(field.entity),
191
+ labelField: field.labelField,
192
+ };
193
+ }
160
194
 
195
+ // Precompute: searchable/sortable fields, searchable reference fields.
196
+ export function buildSearchableSortableCaches(state: RegistryState): void {
161
197
  for (const [name, entity] of state.entityMap) {
162
198
  const searchable: string[] = [];
163
199
  const sortable: string[] = [];
200
+ const searchableReferences: SearchableReferenceField[] = [];
164
201
  for (const [fieldName, field] of Object.entries(entity.fields)) {
165
202
  if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
166
203
  if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
@@ -169,9 +206,13 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
169
206
  if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
170
207
  }
171
208
  }
209
+ if (field.type === "reference" && field.searchable === true) {
210
+ searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
211
+ }
172
212
  }
173
213
  state.searchableFieldsCache.set(name, searchable);
174
214
  state.sortableFieldsCache.set(name, sortable);
215
+ state.searchableReferencesCache.set(name, searchableReferences);
175
216
  }
176
217
  }
177
218
 
@@ -223,16 +264,8 @@ export function buildImplicitProjections(
223
264
  }
224
265
  }
225
266
 
226
- export function buildSearchIncludesAndIncomingRelations(state: RegistryState): void {
267
+ export function buildIncomingRelations(state: RegistryState): void {
227
268
  for (const [entityName, rels] of state.relationMap) {
228
- const includes = new Map<string, readonly string[]>();
229
- for (const [relName, rel] of Object.entries(rels)) {
230
- if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
231
- includes.set(relName, rel.searchInclude);
232
- }
233
- }
234
- state.searchIncludesCache.set(entityName, includes);
235
-
236
269
  // Build reverse index for incoming relations
237
270
  for (const [relName, rel] of Object.entries(rels)) {
238
271
  const existing = state.incomingRelationsCache.get(rel.target) ?? [];
@@ -20,8 +20,8 @@ import {
20
20
  autoWireSoftDeleteJobs,
21
21
  buildEventUpcasterChains,
22
22
  buildImplicitProjections,
23
+ buildIncomingRelations,
23
24
  buildSearchableSortableCaches,
24
- buildSearchIncludesAndIncomingRelations,
25
25
  computeHasRateLimitedHandler,
26
26
  finalizeWorkspaceNavMembership,
27
27
  populateHandlerEntityMappings,
@@ -68,7 +68,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
68
68
  applyExtensionUsages(state);
69
69
  buildSearchableSortableCaches(state);
70
70
  buildImplicitProjections(state, features);
71
- buildSearchIncludesAndIncomingRelations(state);
71
+ buildIncomingRelations(state);
72
72
  validateFieldAccessHandlersAreEntityMapped(state, features);
73
73
  validateExtensionPreSaveWiring(features);
74
74
  validateRelationTargetsExist(state);
@@ -3,6 +3,7 @@ import type {
3
3
  EditFieldSpec,
4
4
  EditFieldsSection,
5
5
  EditSectionSpec,
6
+ EditWriteFormSection,
6
7
  FieldCondition,
7
8
  FormatSpec,
8
9
  ListColumnSpec,
@@ -16,6 +17,10 @@ export function isFieldsEditSection(section: EditSectionSpec): section is EditFi
16
17
  return section.kind === undefined || section.kind === "fields";
17
18
  }
18
19
 
20
+ export function isWriteFormEditSection(section: EditSectionSpec): section is EditWriteFormSection {
21
+ return section.kind === "writeForm";
22
+ }
23
+
19
24
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
20
25
  // authors who branch on the three FieldRenderer variants without manual
21
26
  // "format" in renderer checks.
@@ -275,6 +275,7 @@ export type {
275
275
  EditLayout,
276
276
  EditRelatedListSection,
277
277
  EditSectionSpec,
278
+ EditWriteFormSection,
278
279
  EntityEditScreenDefinition,
279
280
  EntityListScreenDefinition,
280
281
  FieldCondition,
@@ -1,4 +1,8 @@
1
- import { isExtensionEditSection, normalizeListColumn } from "../engine/screen-helpers";
1
+ import {
2
+ isExtensionEditSection,
3
+ isWriteFormEditSection,
4
+ normalizeListColumn,
5
+ } from "../engine/screen-helpers";
2
6
  import type {
3
7
  ActionFormScreenDefinition,
4
8
  ConfigEditScreenDefinition,
@@ -27,6 +31,11 @@ export const CONFIG_EDIT_ENTITY = "__config-edit__";
27
31
  /** Pseudo-entity for projectionDetail field labels (renderer projection-detail-shim). */
28
32
  export const PROJECTION_DETAIL_ENTITY = "__projection-detail__";
29
33
 
34
+ /** Pseudo-entity for a projectionDetail writeForm section's own fieldDefs
35
+ * (distinct from PROJECTION_DETAIL_ENTITY — the section's fields aren't
36
+ * drawn from the host record's display entity). */
37
+ export const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
38
+
30
39
  export function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string {
31
40
  return `${featureName}:entity:${entityName}:field:${fieldName}`;
32
41
  }
@@ -247,6 +256,16 @@ export function requiredKeysFromScreen(
247
256
  const normalized = normalizeListColumn(col);
248
257
  if (normalized.label !== undefined) pushKey(out, normalized.label);
249
258
  }
259
+ for (const action of section.rowActions ?? []) pushRowActionKeys(out, action);
260
+ continue;
261
+ }
262
+ if (isWriteFormEditSection(section)) {
263
+ pushKey(out, section.title);
264
+ pushKey(out, section.submitLabel);
265
+ for (const f of section.fields) {
266
+ const fieldName = editFieldName(f);
267
+ out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
268
+ }
250
269
  continue;
251
270
  }
252
271
  pushKey(out, section.title);
@@ -30,6 +30,7 @@ export {
30
30
  isExtensionEditSection,
31
31
  isFieldsEditSection,
32
32
  isFormatSpec,
33
+ isWriteFormEditSection,
33
34
  normalizeEditField,
34
35
  normalizeListColumn,
35
36
  } from "../engine/screen-helpers";
@@ -74,6 +75,7 @@ export type {
74
75
  EditLayout,
75
76
  EditRelatedListSection,
76
77
  EditSectionSpec,
78
+ EditWriteFormSection,
77
79
  EntityEditScreenDefinition,
78
80
  EntityListScreenDefinition,
79
81
  FieldCondition,
@@ -100,7 +102,10 @@ export type {
100
102
  export type { TargetRef } from "../engine/types/target-ref";
101
103
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
102
104
  export type { WorkspaceDefinition } from "../engine/types/workspace";
103
- export { PROJECTION_DETAIL_ENTITY } from "../i18n/required-surface-keys";
105
+ export {
106
+ PROJECTION_DETAIL_ENTITY,
107
+ WRITE_FORM_SECTION_ENTITY,
108
+ } from "../i18n/required-surface-keys";
104
109
  export type {
105
110
  AppSchema,
106
111
  FeatureSchema,