@entifix/react-integration 0.1.4 → 0.3.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.
@@ -84,6 +84,14 @@ export interface EntityDraftMessages {
84
84
  number(field: string): string;
85
85
  date(field: string): string;
86
86
  option(field: string): string;
87
+ /** A collection holding fewer than its `minItems`. */
88
+ minItems(field: string, count: number): string;
89
+ /** A collection holding more than its `maxItems`. */
90
+ maxItems(field: string, count: number): string;
91
+ /** A date before the one its `notBefore` names. */
92
+ notBefore(field: string, other: string): string;
93
+ /** A localized member with no text for one of the declared locales. */
94
+ locale(field: string, locale: string): string;
87
95
  }
88
96
  /**
89
97
  * Validates a draft against what the metadata implies — `required` members must
@@ -1 +1 @@
1
- {"version":3,"file":"use-entity-form.helpers.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-entity-form/use-entity-form.helpers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"use-entity-form.helpers.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-entity-form/use-entity-form.helpers.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
- import { describeChildColumns, editableChildColumns, EntifixLogicError, EntityCollectionLink, EntityLink, isRowDraftArray, joinFieldPath, readDraftString, readRowDrafts, ROW_KEY, rowFieldPath, seededRowKey } from '@entifix/core';
2
+ import { describeChildColumns, editableChildColumns, EntifixLogicError, EntityCollectionLink, EntityLink, isLocalizedText, isRowDraftArray, joinFieldPath, locales, readDraftString, readLocalizedDraft, readRowDrafts, ROW_KEY, rowFieldPath, seededRowKey } from '@entifix/core';
3
3
  /**
4
4
  * A member no form writes back, whatever the draft says about it — and the only
5
5
  * one is a read-only member, because it is the only one no editor may ever
@@ -54,8 +54,26 @@ import { describeChildColumns, editableChildColumns, EntifixLogicError, EntityCo
54
54
  if (descriptor.type === 'composition') {
55
55
  return seedRowDrafts(descriptor, entity);
56
56
  }
57
+ if (descriptor.type === 'localized') {
58
+ return seedLocalizedDraft(descriptor, entity);
59
+ }
57
60
  return seedScalarValue(descriptor, entity);
58
61
  }
62
+ /**
63
+ * A `localized` member seeds one input per declared locale, each holding that
64
+ * locale's text or `''`. Every declared locale is present even when the record
65
+ * lacks it, so the form shows the empty input a translator has to fill.
66
+ */ function seedLocalizedDraft(descriptor, entity) {
67
+ const raw = entity == null ? void 0 : entity[descriptor.name];
68
+ const text = isLocalizedText(raw) ? raw : {};
69
+ return Object.fromEntries(locales().map((locale)=>{
70
+ var _text_locale;
71
+ return [
72
+ locale,
73
+ (_text_locale = text[locale]) != null ? _text_locale : ''
74
+ ];
75
+ }));
76
+ }
59
77
  /** {@link seedFieldValue} for every member that round-trips through a string. */ function seedScalarValue(descriptor, entity) {
60
78
  if (entity == null) return '';
61
79
  const raw = entity[descriptor.name];
@@ -142,6 +160,14 @@ import { describeChildColumns, editableChildColumns, EntifixLogicError, EntityCo
142
160
  if (isRowDraftArray(value)) restored[descriptor.name] = value;
143
161
  continue;
144
162
  }
163
+ if (descriptor.type === 'localized') {
164
+ // Over the seed rather than instead of it, so a locale declared since
165
+ // the draft was saved still gets its (empty) input.
166
+ if (isLocalizedText(value)) {
167
+ restored[descriptor.name] = _extends({}, readLocalizedDraft(seed, descriptor.name), readLocalizedDraft(persisted, descriptor.name));
168
+ }
169
+ continue;
170
+ }
145
171
  if (typeof value === 'string') restored[descriptor.name] = value;
146
172
  }
147
173
  return restored;
@@ -174,22 +200,82 @@ const labelOf = (descriptor)=>descriptor.label;
174
200
  */ export function validateEntityDraft(descriptors, values, messages, labelFor = labelOf) {
175
201
  const errors = {};
176
202
  for (const descriptor of descriptors){
203
+ var _formatMessage;
177
204
  if (isNeverEdited(descriptor)) continue;
178
205
  if (descriptor.type === 'composition') {
179
206
  Object.assign(errors, validateComposition(descriptor, values, messages, labelFor));
180
207
  continue;
181
208
  }
209
+ if (descriptor.type === 'localized') {
210
+ const message = localizedMessage(descriptor, values, messages, labelFor);
211
+ if (message !== undefined) errors[descriptor.name] = message;
212
+ continue;
213
+ }
182
214
  const raw = readDraftString(values, descriptor.name);
183
215
  if (descriptor.required && raw.trim() === '') {
184
216
  errors[descriptor.name] = messages.required(labelFor(descriptor));
185
217
  continue;
186
218
  }
219
+ // Before the guard below: an empty list is a count of zero, and a link
220
+ // collection's count is checkable though its ids are not.
221
+ const counted = isCountedList(descriptor) ? itemCountMessage(descriptor, countItems(raw), messages, labelFor) : undefined;
222
+ if (counted !== undefined) {
223
+ errors[descriptor.name] = counted;
224
+ continue;
225
+ }
187
226
  if (raw === '' || !hasCheckableFormat(descriptor)) continue;
188
- const message = formatMessage(descriptor, raw, messages, labelFor);
227
+ const message = (_formatMessage = formatMessage(descriptor, raw, messages, labelFor)) != null ? _formatMessage : orderMessage(descriptor, raw, descriptors, values, messages, labelFor);
189
228
  if (message !== undefined) errors[descriptor.name] = message;
190
229
  }
191
230
  return errors;
192
231
  }
232
+ /**
233
+ * A localized member is all or nothing: left wholly empty it is absent, which
234
+ * only `required` objects to; once any locale holds text, every declared one
235
+ * must — the rule `validateRecords` holds stored content to, so a page never
236
+ * shows one language in the middle of another.
237
+ */ function localizedMessage(descriptor, values, messages, labelFor) {
238
+ const draft = readLocalizedDraft(values, descriptor.name);
239
+ const missing = locales().filter((locale)=>{
240
+ var _draft_locale;
241
+ return ((_draft_locale = draft[locale]) != null ? _draft_locale : '').trim() === '';
242
+ });
243
+ if (missing.length === locales().length) {
244
+ return descriptor.required ? messages.required(labelFor(descriptor)) : undefined;
245
+ }
246
+ const [first] = missing;
247
+ return first === undefined ? undefined : messages.locale(labelFor(descriptor), first);
248
+ }
249
+ /** A collection whose draft is a comma list: values, or the ids of links. */ function isCountedList(descriptor) {
250
+ return descriptor.type === 'scalarCollection' || descriptor.type === 'linkCollection';
251
+ }
252
+ /** How many values a draft's comma list holds, blanks aside. */ function countItems(raw) {
253
+ return raw.split(',').filter((item)=>item.trim() !== '').length;
254
+ }
255
+ /**
256
+ * A collection's `minItems` / `maxItems`, judged on its count. The same rule
257
+ * `validateRecords` applies to a stored record, so a draft the form accepts is
258
+ * one a static build would too.
259
+ */ function itemCountMessage(descriptor, count, messages, labelFor) {
260
+ const { minItems, maxItems } = descriptor;
261
+ if (minItems !== undefined && count < minItems) {
262
+ return messages.minItems(labelFor(descriptor), minItems);
263
+ }
264
+ if (maxItems !== undefined && count > maxItems) {
265
+ return messages.maxItems(labelFor(descriptor), maxItems);
266
+ }
267
+ return undefined;
268
+ }
269
+ /**
270
+ * `notBefore`: a filled date that precedes the filled date it names. Either one
271
+ * empty, or unparseable, is someone else's message.
272
+ */ function orderMessage(descriptor, raw, descriptors, values, messages, labelFor) {
273
+ if (descriptor.notBefore === undefined) return undefined;
274
+ const earlier = descriptors.find((other)=>other.name === descriptor.notBefore);
275
+ const start = new Date(readDraftString(values, descriptor.notBefore));
276
+ if (earlier === undefined || Number.isNaN(start.getTime())) return undefined;
277
+ return new Date(raw) < start ? messages.notBefore(labelFor(descriptor), labelFor(earlier)) : undefined;
278
+ }
193
279
  /**
194
280
  * The metadata rule for one filled scalar draft value, or `undefined` when it
195
281
  * passes.
@@ -229,6 +315,8 @@ const labelOf = (descriptor)=>descriptor.label;
229
315
  errors[descriptor.name] = messages.required(labelFor(descriptor));
230
316
  return errors;
231
317
  }
318
+ const counted = itemCountMessage(descriptor, rows.length, messages, labelFor);
319
+ if (counted !== undefined) errors[descriptor.name] = counted;
232
320
  if (descriptor.childType === undefined) return errors;
233
321
  const columns = editableChildColumns(describeChildColumns(descriptor.childType));
234
322
  rows.forEach((row, index)=>{
@@ -332,7 +420,11 @@ const labelOf = (descriptor)=>descriptor.label;
332
420
  required: (field)=>`validation.required:${field}`,
333
421
  number: (field)=>`validation.number:${field}`,
334
422
  date: (field)=>`validation.date:${field}`,
335
- option: (field)=>`validation.option:${field}`
423
+ option: (field)=>`validation.option:${field}`,
424
+ minItems: (field, count)=>`validation.minItems:${field}:${count}`,
425
+ maxItems: (field, count)=>`validation.maxItems:${field}:${count}`,
426
+ notBefore: (field, other)=>`validation.notBefore:${field}:${other}`,
427
+ locale: (field, locale)=>`validation.locale:${field}:${locale}`
336
428
  };
337
429
 
338
430
  //# sourceMappingURL=use-entity-form.helpers.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/use-entity-form/use-entity-form.helpers.ts"],"sourcesContent":["import {\n describeChildColumns,\n editableChildColumns,\n EntifixLogicError,\n EntityCollectionLink,\n type EntityDraft,\n type EntityDraftValue,\n type EntityFieldDescriptor,\n EntityLink,\n type EntityRowDraft,\n isRowDraftArray,\n joinFieldPath,\n readDraftString,\n readRowDrafts,\n ROW_KEY,\n rowFieldPath,\n seededRowKey,\n type StandardSchemaV1,\n type StandardSchemaV1Issue,\n} from '@entifix/core';\n\n/**\n * A member no form writes back, whatever the draft says about it — and the only\n * one is a read-only member, because it is the only one no editor may ever\n * exist for.\n *\n * `linkCollection` used to be here too, which meant `required` on a to-many\n * relation was silently unenforced: a rule the entity declared and the form\n * never applied. Whether a collection has an editor yet is a question about the\n * controls, not about what the domain insists on, so it is excluded from the\n * *format* check below instead.\n */\nfunction isNeverEdited(descriptor: EntityFieldDescriptor): boolean {\n return descriptor.readonly;\n}\n\n/**\n * A member whose *format* the metadata can judge. Both relation shapes are\n * excluded: their draft value is a foreign key (or a joined list of them), and\n * \"is this a real id\" is a question only the service can answer — but whether\n * one is present at all is checked, which is what `required` on a relation has\n * to mean.\n *\n * A `composition` is excluded here too, but it is no longer unjudged: its rows\n * are checked one at a time by {@link validateRowDrafts}, against the child's\n * own descriptors. What has no meaning is a *format* rule on the collection\n * itself — there is no string to judge, only rows.\n *\n * `scalarCollection` is deliberately *not* excluded — its comma list is real\n * text the user typed.\n */\nfunction hasCheckableFormat(descriptor: EntityFieldDescriptor): boolean {\n return (\n descriptor.type !== 'link' &&\n descriptor.type !== 'linkCollection' &&\n descriptor.type !== 'composition'\n );\n}\n\n/**\n * The value a field seeds with from a record.\n *\n * A scalar seeds as a **string**: links with their foreign key(s), dates with a\n * `yyyy-mm-dd` value a `date` input accepts, everything else stringified.\n *\n * The array branch is explicit rather than left to `String(raw)`, even though\n * the two produce the same characters today. `String(['a','b'])` is `'a,b'` by\n * way of `Array.prototype.toString`, which is an accident: it was doing the\n * work of a `scalarCollection` seed before the type existed, and\n * `reconstructEntity` was handing the same string straight back as a `string`.\n * Both halves were wrong in the same direction, so the fixed-point spec could\n * not see it. Now the join is declared here and the split is declared there,\n * and they are inverses on purpose.\n *\n * A **`composition`** is the one member that does not seed as a string, because\n * it has no lossless string form at all — its rows seed as row drafts, each\n * with a freshly minted key. That key is the only thing here the record does not\n * supply, and it is minted at seed time rather than at render so that the rows a\n * form starts with are already addressable.\n */\nexport function seedFieldValue(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityDraftValue {\n if (descriptor.type === 'composition') {\n return seedRowDrafts(descriptor, entity);\n }\n return seedScalarValue(descriptor, entity);\n}\n\n/** {@link seedFieldValue} for every member that round-trips through a string. */\nfunction seedScalarValue(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): string {\n if (entity == null) return '';\n const raw = (entity as Record<string, unknown>)[descriptor.name];\n if (raw == null) return '';\n if (raw instanceof EntityLink) return raw.id == null ? '' : String(raw.id);\n if (raw instanceof EntityCollectionLink) return raw.ids.map(String).join(',');\n if (raw instanceof Date) return raw.toISOString().slice(0, 10);\n if (Array.isArray(raw)) return raw.map(String).join(',');\n return String(raw);\n}\n\n/**\n * The rows a composition member seeds with.\n *\n * Each row is walked with the child's **own** descriptors, through the same\n * `seedScalarValue` the master's members use — so a child's `date` seeds as\n * `yyyy-mm-dd` on a line exactly as it would on a record, and\n * `reconstructChild` is its inverse for the same reason `coerceFieldValue` is.\n *\n * A row arrives as a plain object as readily as an instance (a payload off the\n * wire, a fixture), which is fine: nothing here asks what class it is, matching\n * `ChildConstructor`'s rule that a child is never `instanceof`-tested.\n *\n * A member with no declared child seeds as no rows — there are no columns to\n * walk, so there is nothing a row could be.\n */\nfunction seedRowDrafts(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityRowDraft[] {\n if (entity == null || descriptor.childType === undefined) return [];\n const raw = (entity as Record<string, unknown>)[descriptor.name];\n if (!Array.isArray(raw)) return [];\n\n const columns = describeChildColumns(descriptor.childType);\n\n return raw.map((row, index) => {\n // Deterministic, so re-seeding the same record produces the same draft —\n // see `seededRowKey` for the render loop the random one causes.\n const draft: EntityRowDraft = { [ROW_KEY]: seededRowKey(index) };\n for (const column of columns) {\n draft[column.name] = seedScalarValue(column, row);\n }\n return draft;\n });\n}\n\n/** Builds the initial draft for a record (or an empty one for a create). */\nexport function seedEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n entity: unknown,\n): EntityDraft {\n const draft: EntityDraft = {};\n for (const descriptor of descriptors) {\n draft[descriptor.name] = seedFieldValue(descriptor, entity);\n }\n return draft;\n}\n\n/**\n * Restores a persisted draft **over** a freshly seeded one, rather than in place\n * of it.\n *\n * A draft outlives the entity that wrote it — it sits in IndexedDB across\n * refreshes and deployments — so the two can disagree by the time it comes back.\n * Replacing the seed outright is how that disagreement becomes a bug the user\n * sees: a member added since the draft was written has no entry, reaches the form\n * engine as `undefined`, and its input flips from controlled to uncontrolled\n * mid-render. A member since removed goes the other way, surviving as a value\n * nothing renders and `reconstructEntity` cannot assign.\n *\n * So the seed decides the *keys* and the draft decides the *values*: a name the\n * entity no longer declares is dropped, and a name it declares that the draft\n * lacks keeps its seeded value.\n *\n * **A value is restored only in the shape its member can hold**, which is now\n * two shapes rather than one. A scalar takes a string; a `composition` takes a\n * readable row list. Anything else is dropped back to its seed — a member that\n * changed type between the write and the read, or a row list from a build that\n * did not carry keys, restores as the record's own value instead of as\n * something no editor can render. This is per member, so one unreadable entry\n * never costs the user the rest of the form.\n *\n * This is the shape guard at the one place the shape is actually known. The\n * store-level version check (`drafts-state.ts`) can only decide that a whole\n * generation of drafts is unreadable; it cannot know which members one entity\n * declares.\n */\nexport function restoreEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n seed: EntityDraft,\n persisted: EntityDraft | undefined,\n): EntityDraft {\n if (persisted === undefined) return seed;\n\n const restored: EntityDraft = { ...seed };\n for (const descriptor of descriptors) {\n const value = persisted[descriptor.name];\n if (value === undefined) continue;\n\n if (descriptor.type === 'composition') {\n if (isRowDraftArray(value)) restored[descriptor.name] = value;\n continue;\n }\n if (typeof value === 'string') restored[descriptor.name] = value;\n }\n return restored;\n}\n\n/** The slice of a form field's state this module reads: its error list. */\nexport interface FieldErrorMeta {\n errors?: readonly unknown[];\n}\n\n/**\n * Flattens the form engine's per-field state into the `name → message` map the\n * agnostic `EntityForm` renders. Only the first message per field survives: a\n * row shows one line, and the metadata rules already stop at the first failure.\n *\n * Kept structural rather than typed against the engine so the rest of this\n * module stays independent of which form library backs the hook.\n */\nexport function readFieldErrors(\n fieldMeta: Partial<Record<string, FieldErrorMeta | undefined>>,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n\n for (const [name, meta] of Object.entries(fieldMeta)) {\n const message = meta?.errors?.find(entry => typeof entry === 'string');\n if (typeof message === 'string') errors[name] = message;\n }\n\n return errors;\n}\n\n/**\n * The four metadata-derived messages, already localized. Taken as an argument\n * rather than built here so this stays a pure function — and because the\n * sentence cannot be assembled from a label plus a hardcoded suffix: word order\n * differs between locales.\n */\n/**\n * How a message names a field. The default is the descriptor's `label`, which is\n * the accessor's `label` option or its humanized member name — never translated.\n * A host with a catalog resolves `labelKey` instead, so the message names the\n * field in the words its input and column header already use.\n */\nexport type EntityFieldLabeler = (descriptor: EntityFieldDescriptor) => string;\n\nconst labelOf: EntityFieldLabeler = descriptor => descriptor.label;\n\nexport interface EntityDraftMessages {\n required(field: string): string;\n number(field: string): string;\n date(field: string): string;\n option(field: string): string;\n}\n\n/**\n * Validates a draft against what the metadata implies — `required` members must\n * be filled, and a filled `number`/`date`/`enum` must be well-formed. Read-only\n * members are skipped entirely; a relation is checked for presence but never\n * for format, since its draft value is a foreign key this side cannot resolve.\n *\n * Metadata is only the first of three rule sources; {@link composeEntityFormErrors}\n * is what layers a schema and the caller's own rules over this.\n */\nexport function validateEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n values: EntityDraft,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler = labelOf,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n\n for (const descriptor of descriptors) {\n if (isNeverEdited(descriptor)) continue;\n\n if (descriptor.type === 'composition') {\n Object.assign(\n errors,\n validateComposition(descriptor, values, messages, labelFor),\n );\n continue;\n }\n\n const raw = readDraftString(values, descriptor.name);\n\n if (descriptor.required && raw.trim() === '') {\n errors[descriptor.name] = messages.required(labelFor(descriptor));\n continue;\n }\n if (raw === '' || !hasCheckableFormat(descriptor)) continue;\n\n const message = formatMessage(descriptor, raw, messages, labelFor);\n if (message !== undefined) errors[descriptor.name] = message;\n }\n\n return errors;\n}\n\n/**\n * The metadata rule for one filled scalar draft value, or `undefined` when it\n * passes.\n *\n * Extracted so a row's cell is judged by the **same** rule as a record's field.\n * Two copies is how a child's `number` member would quietly start accepting\n * something its master's would reject.\n */\nfunction formatMessage(\n descriptor: EntityFieldDescriptor,\n raw: string,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): string | undefined {\n if (descriptor.type === 'number' && Number.isNaN(Number(raw))) {\n return messages.number(labelFor(descriptor));\n }\n if (descriptor.type === 'date' && Number.isNaN(new Date(raw).getTime())) {\n return messages.date(labelFor(descriptor));\n }\n if (\n descriptor.type === 'enum' &&\n descriptor.enumValues &&\n !descriptor.enumValues.includes(raw)\n ) {\n return messages.option(labelFor(descriptor));\n }\n return undefined;\n}\n\n/**\n * The rules an owned collection carries, at both of its levels.\n *\n * **`required` on the collection means \"at least one row\"** — the only thing a\n * master can assert about a collection it owns, and a genuinely different fact\n * from `required` on a child member, which is per row. Conflating the two would\n * make an order with three lines, one of them blank, indistinguishable from an\n * order with none.\n *\n * A row's own members are judged against the child's descriptors and reported at\n * `items[2].quantity`, so the grid can put the message in the cell that caused\n * it. Read-only child members are skipped for the reason they always are: no\n * editor exists for them, so no rule the user could satisfy applies.\n */\nfunction validateComposition(\n descriptor: EntityFieldDescriptor,\n values: EntityDraft,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n const rows = readRowDrafts(values[descriptor.name]);\n\n if (descriptor.required && rows.length === 0) {\n errors[descriptor.name] = messages.required(labelFor(descriptor));\n return errors;\n }\n if (descriptor.childType === undefined) return errors;\n\n const columns = editableChildColumns(\n describeChildColumns(descriptor.childType),\n );\n\n rows.forEach((row, index) => {\n for (const column of columns) {\n if (isNeverEdited(column)) continue;\n\n const raw = row[column.name] ?? '';\n const path = rowFieldPath(descriptor.name, index, column.name);\n\n if (column.required && raw.trim() === '') {\n errors[path] = messages.required(labelFor(column));\n continue;\n }\n if (raw === '') continue;\n\n const message = formatMessage(column, raw, messages, labelFor);\n if (message !== undefined) errors[path] = message;\n }\n });\n\n return errors;\n}\n\n/**\n * The error-map key an issue belongs to — the **whole** path, not its head.\n *\n * It used to read `issue.path?.[0]`, which was right while every member was a\n * scalar and wrong the moment one held rows: `['items', 2, 'quantity']`\n * collapsed to `'items'`, so a rule about the third line's quantity reported\n * itself against the collection and the grid had no way to know which cell it\n * meant. Joining instead produces `items[2].quantity`, which is the key\n * `validateComposition` already writes — so a schema rule and a metadata rule\n * address the same cell the same way and `composeEntityFormErrors` can merge\n * them.\n *\n * A one-segment path still yields exactly the head, so no existing rule changes\n * meaning.\n */\nfunction issueFieldName(issue: StandardSchemaV1Issue): string | undefined {\n const path = issue.path;\n if (path == null || path.length === 0) return undefined;\n\n const segments = path.map(segment => {\n const raw =\n typeof segment === 'object' && segment !== null && 'key' in segment\n ? segment.key\n : segment;\n return typeof raw === 'number' ? raw : String(raw);\n });\n\n return joinFieldPath(segments);\n}\n\n/**\n * Runs a Standard Schema over the draft and sorts its issues into per-field\n * messages plus a form-level one (an issue with no path — a cross-field rule).\n *\n * **Schemas are authored against the string draft**, so they coerce\n * (`z.coerce.number()`) rather than expect typed values: the draft is what the\n * inputs round-trip, and the same schema then validates exactly what the user\n * sees. Only the first issue per field survives, matching how a row renders.\n *\n * Synchronous rules only. An async schema returns a Promise the form's\n * synchronous validation pass cannot wait on, so it is rejected loudly rather\n * than silently passing.\n */\nexport function readSchemaIssues(\n schema: StandardSchemaV1,\n values: EntityDraft,\n translateIssue: (message: string, field: string | undefined) => string,\n): { fields: Record<string, string>; form?: string } {\n const result = schema['~standard'].validate(values);\n\n if (result instanceof Promise) {\n throw new EntifixLogicError(\n 'useEntityForm cannot run an async schema: its validation pass is ' +\n 'synchronous. Move the asynchronous rule into the `validate` option.',\n );\n }\n if (!result.issues) return { fields: {} };\n\n const fields: Record<string, string> = {};\n let form: string | undefined;\n\n for (const issue of result.issues) {\n const name = issueFieldName(issue);\n const message = translateIssue(issue.message, name);\n\n if (name === undefined) form ??= message;\n else fields[name] ??= message;\n }\n\n return { fields, form };\n}\n\n/** Everything {@link composeEntityFormErrors} needs to judge one draft. */\nexport interface ComposeEntityFormErrorsOptions {\n descriptors: readonly EntityFieldDescriptor[];\n values: EntityDraft;\n messages: EntityDraftMessages;\n /** How a metadata message names its field. Defaults to the untranslated `label`. */\n labelFor?: EntityFieldLabeler;\n schema?: StandardSchemaV1;\n translateIssue: (message: string, field: string | undefined) => string;\n validate?: (values: EntityDraft) => Record<string, string>;\n}\n\n/**\n * The single composition point for a draft's errors: metadata rules first, then\n * the entity's schema, then the caller's `validate` callback. Later sources win\n * on conflict, so a caller can always override a message it disagrees with.\n */\nexport function composeEntityFormErrors({\n descriptors,\n values,\n messages,\n labelFor,\n schema,\n translateIssue,\n validate,\n}: ComposeEntityFormErrorsOptions): {\n fields: Record<string, string>;\n form?: string;\n} {\n const metadata = validateEntityDraft(descriptors, values, messages, labelFor);\n const schemaIssues = schema\n ? readSchemaIssues(schema, values, translateIssue)\n : { fields: {}, form: undefined };\n\n return {\n fields: { ...metadata, ...schemaIssues.fields, ...validate?.(values) },\n form: schemaIssues.form,\n };\n}\n\n/**\n * What a caller who supplies no {@link ValidationMessages} gets: the catalog key\n * itself, with the field appended.\n *\n * Honest rather than English — there is no catalog in this package to resolve\n * against — and visible enough in a form that a host notices it has wired no\n * copy. It sits beside the validator rather than in `.types.ts`, which this\n * repository keeps type-only.\n */\nexport const DEFAULT_VALIDATION_MESSAGES: EntityDraftMessages = {\n required: field => `validation.required:${field}`,\n number: field => `validation.number:${field}`,\n date: field => `validation.date:${field}`,\n option: field => `validation.option:${field}`,\n};\n"],"names":["describeChildColumns","editableChildColumns","EntifixLogicError","EntityCollectionLink","EntityLink","isRowDraftArray","joinFieldPath","readDraftString","readRowDrafts","ROW_KEY","rowFieldPath","seededRowKey","isNeverEdited","descriptor","readonly","hasCheckableFormat","type","seedFieldValue","entity","seedRowDrafts","seedScalarValue","raw","name","id","String","ids","map","join","Date","toISOString","slice","Array","isArray","childType","undefined","columns","row","index","draft","column","seedEntityDraft","descriptors","restoreEntityDraft","seed","persisted","restored","value","readFieldErrors","fieldMeta","errors","meta","Object","entries","message","find","entry","labelOf","label","validateEntityDraft","values","messages","labelFor","assign","validateComposition","required","trim","formatMessage","Number","isNaN","number","getTime","date","enumValues","includes","option","rows","length","forEach","path","issueFieldName","issue","segments","segment","key","readSchemaIssues","schema","translateIssue","result","validate","Promise","issues","fields","form","composeEntityFormErrors","metadata","schemaIssues","DEFAULT_VALIDATION_MESSAGES","field"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/hooks/use-entity-form/use-entity-form.helpers.ts"],"sourcesContent":["import {\n describeChildColumns,\n editableChildColumns,\n EntifixLogicError,\n EntityCollectionLink,\n type EntityDraft,\n type EntityDraftValue,\n type EntityFieldDescriptor,\n EntityLink,\n type EntityLocalizedDraft,\n type EntityRowDraft,\n isLocalizedText,\n isRowDraftArray,\n joinFieldPath,\n locales,\n readDraftString,\n readLocalizedDraft,\n readRowDrafts,\n ROW_KEY,\n rowFieldPath,\n seededRowKey,\n type StandardSchemaV1,\n type StandardSchemaV1Issue,\n} from '@entifix/core';\n\n/**\n * A member no form writes back, whatever the draft says about it — and the only\n * one is a read-only member, because it is the only one no editor may ever\n * exist for.\n *\n * `linkCollection` used to be here too, which meant `required` on a to-many\n * relation was silently unenforced: a rule the entity declared and the form\n * never applied. Whether a collection has an editor yet is a question about the\n * controls, not about what the domain insists on, so it is excluded from the\n * *format* check below instead.\n */\nfunction isNeverEdited(descriptor: EntityFieldDescriptor): boolean {\n return descriptor.readonly;\n}\n\n/**\n * A member whose *format* the metadata can judge. Both relation shapes are\n * excluded: their draft value is a foreign key (or a joined list of them), and\n * \"is this a real id\" is a question only the service can answer — but whether\n * one is present at all is checked, which is what `required` on a relation has\n * to mean.\n *\n * A `composition` is excluded here too, but it is no longer unjudged: its rows\n * are checked one at a time by {@link validateRowDrafts}, against the child's\n * own descriptors. What has no meaning is a *format* rule on the collection\n * itself — there is no string to judge, only rows.\n *\n * `scalarCollection` is deliberately *not* excluded — its comma list is real\n * text the user typed.\n */\nfunction hasCheckableFormat(descriptor: EntityFieldDescriptor): boolean {\n return (\n descriptor.type !== 'link' &&\n descriptor.type !== 'linkCollection' &&\n descriptor.type !== 'composition'\n );\n}\n\n/**\n * The value a field seeds with from a record.\n *\n * A scalar seeds as a **string**: links with their foreign key(s), dates with a\n * `yyyy-mm-dd` value a `date` input accepts, everything else stringified.\n *\n * The array branch is explicit rather than left to `String(raw)`, even though\n * the two produce the same characters today. `String(['a','b'])` is `'a,b'` by\n * way of `Array.prototype.toString`, which is an accident: it was doing the\n * work of a `scalarCollection` seed before the type existed, and\n * `reconstructEntity` was handing the same string straight back as a `string`.\n * Both halves were wrong in the same direction, so the fixed-point spec could\n * not see it. Now the join is declared here and the split is declared there,\n * and they are inverses on purpose.\n *\n * A **`composition`** is the one member that does not seed as a string, because\n * it has no lossless string form at all — its rows seed as row drafts, each\n * with a freshly minted key. That key is the only thing here the record does not\n * supply, and it is minted at seed time rather than at render so that the rows a\n * form starts with are already addressable.\n */\nexport function seedFieldValue(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityDraftValue {\n if (descriptor.type === 'composition') {\n return seedRowDrafts(descriptor, entity);\n }\n if (descriptor.type === 'localized') {\n return seedLocalizedDraft(descriptor, entity);\n }\n return seedScalarValue(descriptor, entity);\n}\n\n/**\n * A `localized` member seeds one input per declared locale, each holding that\n * locale's text or `''`. Every declared locale is present even when the record\n * lacks it, so the form shows the empty input a translator has to fill.\n */\nfunction seedLocalizedDraft(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityLocalizedDraft {\n const raw = (entity as Record<string, unknown> | null | undefined)?.[\n descriptor.name\n ];\n const text = isLocalizedText(raw) ? raw : {};\n return Object.fromEntries(\n locales().map(locale => [locale, text[locale] ?? '']),\n );\n}\n\n/** {@link seedFieldValue} for every member that round-trips through a string. */\nfunction seedScalarValue(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): string {\n if (entity == null) return '';\n const raw = (entity as Record<string, unknown>)[descriptor.name];\n if (raw == null) return '';\n if (raw instanceof EntityLink) return raw.id == null ? '' : String(raw.id);\n if (raw instanceof EntityCollectionLink) return raw.ids.map(String).join(',');\n if (raw instanceof Date) return raw.toISOString().slice(0, 10);\n if (Array.isArray(raw)) return raw.map(String).join(',');\n return String(raw);\n}\n\n/**\n * The rows a composition member seeds with.\n *\n * Each row is walked with the child's **own** descriptors, through the same\n * `seedScalarValue` the master's members use — so a child's `date` seeds as\n * `yyyy-mm-dd` on a line exactly as it would on a record, and\n * `reconstructChild` is its inverse for the same reason `coerceFieldValue` is.\n *\n * A row arrives as a plain object as readily as an instance (a payload off the\n * wire, a fixture), which is fine: nothing here asks what class it is, matching\n * `ChildConstructor`'s rule that a child is never `instanceof`-tested.\n *\n * A member with no declared child seeds as no rows — there are no columns to\n * walk, so there is nothing a row could be.\n */\nfunction seedRowDrafts(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityRowDraft[] {\n if (entity == null || descriptor.childType === undefined) return [];\n const raw = (entity as Record<string, unknown>)[descriptor.name];\n if (!Array.isArray(raw)) return [];\n\n const columns = describeChildColumns(descriptor.childType);\n\n return raw.map((row, index) => {\n // Deterministic, so re-seeding the same record produces the same draft —\n // see `seededRowKey` for the render loop the random one causes.\n const draft: EntityRowDraft = { [ROW_KEY]: seededRowKey(index) };\n for (const column of columns) {\n draft[column.name] = seedScalarValue(column, row);\n }\n return draft;\n });\n}\n\n/** Builds the initial draft for a record (or an empty one for a create). */\nexport function seedEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n entity: unknown,\n): EntityDraft {\n const draft: EntityDraft = {};\n for (const descriptor of descriptors) {\n draft[descriptor.name] = seedFieldValue(descriptor, entity);\n }\n return draft;\n}\n\n/**\n * Restores a persisted draft **over** a freshly seeded one, rather than in place\n * of it.\n *\n * A draft outlives the entity that wrote it — it sits in IndexedDB across\n * refreshes and deployments — so the two can disagree by the time it comes back.\n * Replacing the seed outright is how that disagreement becomes a bug the user\n * sees: a member added since the draft was written has no entry, reaches the form\n * engine as `undefined`, and its input flips from controlled to uncontrolled\n * mid-render. A member since removed goes the other way, surviving as a value\n * nothing renders and `reconstructEntity` cannot assign.\n *\n * So the seed decides the *keys* and the draft decides the *values*: a name the\n * entity no longer declares is dropped, and a name it declares that the draft\n * lacks keeps its seeded value.\n *\n * **A value is restored only in the shape its member can hold**, which is now\n * two shapes rather than one. A scalar takes a string; a `composition` takes a\n * readable row list. Anything else is dropped back to its seed — a member that\n * changed type between the write and the read, or a row list from a build that\n * did not carry keys, restores as the record's own value instead of as\n * something no editor can render. This is per member, so one unreadable entry\n * never costs the user the rest of the form.\n *\n * This is the shape guard at the one place the shape is actually known. The\n * store-level version check (`drafts-state.ts`) can only decide that a whole\n * generation of drafts is unreadable; it cannot know which members one entity\n * declares.\n */\nexport function restoreEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n seed: EntityDraft,\n persisted: EntityDraft | undefined,\n): EntityDraft {\n if (persisted === undefined) return seed;\n\n const restored: EntityDraft = { ...seed };\n for (const descriptor of descriptors) {\n const value = persisted[descriptor.name];\n if (value === undefined) continue;\n\n if (descriptor.type === 'composition') {\n if (isRowDraftArray(value)) restored[descriptor.name] = value;\n continue;\n }\n if (descriptor.type === 'localized') {\n // Over the seed rather than instead of it, so a locale declared since\n // the draft was saved still gets its (empty) input.\n if (isLocalizedText(value)) {\n restored[descriptor.name] = {\n ...readLocalizedDraft(seed, descriptor.name),\n ...readLocalizedDraft(persisted, descriptor.name),\n };\n }\n continue;\n }\n if (typeof value === 'string') restored[descriptor.name] = value;\n }\n return restored;\n}\n\n/** The slice of a form field's state this module reads: its error list. */\nexport interface FieldErrorMeta {\n errors?: readonly unknown[];\n}\n\n/**\n * Flattens the form engine's per-field state into the `name → message` map the\n * agnostic `EntityForm` renders. Only the first message per field survives: a\n * row shows one line, and the metadata rules already stop at the first failure.\n *\n * Kept structural rather than typed against the engine so the rest of this\n * module stays independent of which form library backs the hook.\n */\nexport function readFieldErrors(\n fieldMeta: Partial<Record<string, FieldErrorMeta | undefined>>,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n\n for (const [name, meta] of Object.entries(fieldMeta)) {\n const message = meta?.errors?.find(entry => typeof entry === 'string');\n if (typeof message === 'string') errors[name] = message;\n }\n\n return errors;\n}\n\n/**\n * The four metadata-derived messages, already localized. Taken as an argument\n * rather than built here so this stays a pure function — and because the\n * sentence cannot be assembled from a label plus a hardcoded suffix: word order\n * differs between locales.\n */\n/**\n * How a message names a field. The default is the descriptor's `label`, which is\n * the accessor's `label` option or its humanized member name — never translated.\n * A host with a catalog resolves `labelKey` instead, so the message names the\n * field in the words its input and column header already use.\n */\nexport type EntityFieldLabeler = (descriptor: EntityFieldDescriptor) => string;\n\nconst labelOf: EntityFieldLabeler = descriptor => descriptor.label;\n\nexport interface EntityDraftMessages {\n required(field: string): string;\n number(field: string): string;\n date(field: string): string;\n option(field: string): string;\n /** A collection holding fewer than its `minItems`. */\n minItems(field: string, count: number): string;\n /** A collection holding more than its `maxItems`. */\n maxItems(field: string, count: number): string;\n /** A date before the one its `notBefore` names. */\n notBefore(field: string, other: string): string;\n /** A localized member with no text for one of the declared locales. */\n locale(field: string, locale: string): string;\n}\n\n/**\n * Validates a draft against what the metadata implies — `required` members must\n * be filled, and a filled `number`/`date`/`enum` must be well-formed. Read-only\n * members are skipped entirely; a relation is checked for presence but never\n * for format, since its draft value is a foreign key this side cannot resolve.\n *\n * Metadata is only the first of three rule sources; {@link composeEntityFormErrors}\n * is what layers a schema and the caller's own rules over this.\n */\nexport function validateEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n values: EntityDraft,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler = labelOf,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n\n for (const descriptor of descriptors) {\n if (isNeverEdited(descriptor)) continue;\n\n if (descriptor.type === 'composition') {\n Object.assign(\n errors,\n validateComposition(descriptor, values, messages, labelFor),\n );\n continue;\n }\n if (descriptor.type === 'localized') {\n const message = localizedMessage(descriptor, values, messages, labelFor);\n if (message !== undefined) errors[descriptor.name] = message;\n continue;\n }\n\n const raw = readDraftString(values, descriptor.name);\n\n if (descriptor.required && raw.trim() === '') {\n errors[descriptor.name] = messages.required(labelFor(descriptor));\n continue;\n }\n // Before the guard below: an empty list is a count of zero, and a link\n // collection's count is checkable though its ids are not.\n const counted = isCountedList(descriptor)\n ? itemCountMessage(descriptor, countItems(raw), messages, labelFor)\n : undefined;\n if (counted !== undefined) {\n errors[descriptor.name] = counted;\n continue;\n }\n if (raw === '' || !hasCheckableFormat(descriptor)) continue;\n\n const message =\n formatMessage(descriptor, raw, messages, labelFor) ??\n orderMessage(descriptor, raw, descriptors, values, messages, labelFor);\n if (message !== undefined) errors[descriptor.name] = message;\n }\n\n return errors;\n}\n\n/**\n * A localized member is all or nothing: left wholly empty it is absent, which\n * only `required` objects to; once any locale holds text, every declared one\n * must — the rule `validateRecords` holds stored content to, so a page never\n * shows one language in the middle of another.\n */\nfunction localizedMessage(\n descriptor: EntityFieldDescriptor,\n values: EntityDraft,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): string | undefined {\n const draft = readLocalizedDraft(values, descriptor.name);\n const missing = locales().filter(\n locale => (draft[locale] ?? '').trim() === '',\n );\n if (missing.length === locales().length) {\n return descriptor.required\n ? messages.required(labelFor(descriptor))\n : undefined;\n }\n const [first] = missing;\n return first === undefined\n ? undefined\n : messages.locale(labelFor(descriptor), first);\n}\n\n/** A collection whose draft is a comma list: values, or the ids of links. */\nfunction isCountedList(descriptor: EntityFieldDescriptor): boolean {\n return (\n descriptor.type === 'scalarCollection' ||\n descriptor.type === 'linkCollection'\n );\n}\n\n/** How many values a draft's comma list holds, blanks aside. */\nfunction countItems(raw: string): number {\n return raw.split(',').filter(item => item.trim() !== '').length;\n}\n\n/**\n * A collection's `minItems` / `maxItems`, judged on its count. The same rule\n * `validateRecords` applies to a stored record, so a draft the form accepts is\n * one a static build would too.\n */\nfunction itemCountMessage(\n descriptor: EntityFieldDescriptor,\n count: number,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): string | undefined {\n const { minItems, maxItems } = descriptor;\n if (minItems !== undefined && count < minItems) {\n return messages.minItems(labelFor(descriptor), minItems);\n }\n if (maxItems !== undefined && count > maxItems) {\n return messages.maxItems(labelFor(descriptor), maxItems);\n }\n return undefined;\n}\n\n/**\n * `notBefore`: a filled date that precedes the filled date it names. Either one\n * empty, or unparseable, is someone else's message.\n */\nfunction orderMessage(\n descriptor: EntityFieldDescriptor,\n raw: string,\n descriptors: readonly EntityFieldDescriptor[],\n values: EntityDraft,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): string | undefined {\n if (descriptor.notBefore === undefined) return undefined;\n const earlier = descriptors.find(\n other => other.name === descriptor.notBefore,\n );\n const start = new Date(readDraftString(values, descriptor.notBefore));\n if (earlier === undefined || Number.isNaN(start.getTime())) return undefined;\n return new Date(raw) < start\n ? messages.notBefore(labelFor(descriptor), labelFor(earlier))\n : undefined;\n}\n\n/**\n * The metadata rule for one filled scalar draft value, or `undefined` when it\n * passes.\n *\n * Extracted so a row's cell is judged by the **same** rule as a record's field.\n * Two copies is how a child's `number` member would quietly start accepting\n * something its master's would reject.\n */\nfunction formatMessage(\n descriptor: EntityFieldDescriptor,\n raw: string,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): string | undefined {\n if (descriptor.type === 'number' && Number.isNaN(Number(raw))) {\n return messages.number(labelFor(descriptor));\n }\n if (descriptor.type === 'date' && Number.isNaN(new Date(raw).getTime())) {\n return messages.date(labelFor(descriptor));\n }\n if (\n descriptor.type === 'enum' &&\n descriptor.enumValues &&\n !descriptor.enumValues.includes(raw)\n ) {\n return messages.option(labelFor(descriptor));\n }\n return undefined;\n}\n\n/**\n * The rules an owned collection carries, at both of its levels.\n *\n * **`required` on the collection means \"at least one row\"** — the only thing a\n * master can assert about a collection it owns, and a genuinely different fact\n * from `required` on a child member, which is per row. Conflating the two would\n * make an order with three lines, one of them blank, indistinguishable from an\n * order with none.\n *\n * A row's own members are judged against the child's descriptors and reported at\n * `items[2].quantity`, so the grid can put the message in the cell that caused\n * it. Read-only child members are skipped for the reason they always are: no\n * editor exists for them, so no rule the user could satisfy applies.\n */\nfunction validateComposition(\n descriptor: EntityFieldDescriptor,\n values: EntityDraft,\n messages: EntityDraftMessages,\n labelFor: EntityFieldLabeler,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n const rows = readRowDrafts(values[descriptor.name]);\n\n if (descriptor.required && rows.length === 0) {\n errors[descriptor.name] = messages.required(labelFor(descriptor));\n return errors;\n }\n const counted = itemCountMessage(descriptor, rows.length, messages, labelFor);\n if (counted !== undefined) errors[descriptor.name] = counted;\n if (descriptor.childType === undefined) return errors;\n\n const columns = editableChildColumns(\n describeChildColumns(descriptor.childType),\n );\n\n rows.forEach((row, index) => {\n for (const column of columns) {\n if (isNeverEdited(column)) continue;\n\n const raw = row[column.name] ?? '';\n const path = rowFieldPath(descriptor.name, index, column.name);\n\n if (column.required && raw.trim() === '') {\n errors[path] = messages.required(labelFor(column));\n continue;\n }\n if (raw === '') continue;\n\n const message = formatMessage(column, raw, messages, labelFor);\n if (message !== undefined) errors[path] = message;\n }\n });\n\n return errors;\n}\n\n/**\n * The error-map key an issue belongs to — the **whole** path, not its head.\n *\n * It used to read `issue.path?.[0]`, which was right while every member was a\n * scalar and wrong the moment one held rows: `['items', 2, 'quantity']`\n * collapsed to `'items'`, so a rule about the third line's quantity reported\n * itself against the collection and the grid had no way to know which cell it\n * meant. Joining instead produces `items[2].quantity`, which is the key\n * `validateComposition` already writes — so a schema rule and a metadata rule\n * address the same cell the same way and `composeEntityFormErrors` can merge\n * them.\n *\n * A one-segment path still yields exactly the head, so no existing rule changes\n * meaning.\n */\nfunction issueFieldName(issue: StandardSchemaV1Issue): string | undefined {\n const path = issue.path;\n if (path == null || path.length === 0) return undefined;\n\n const segments = path.map(segment => {\n const raw =\n typeof segment === 'object' && segment !== null && 'key' in segment\n ? segment.key\n : segment;\n return typeof raw === 'number' ? raw : String(raw);\n });\n\n return joinFieldPath(segments);\n}\n\n/**\n * Runs a Standard Schema over the draft and sorts its issues into per-field\n * messages plus a form-level one (an issue with no path — a cross-field rule).\n *\n * **Schemas are authored against the string draft**, so they coerce\n * (`z.coerce.number()`) rather than expect typed values: the draft is what the\n * inputs round-trip, and the same schema then validates exactly what the user\n * sees. Only the first issue per field survives, matching how a row renders.\n *\n * Synchronous rules only. An async schema returns a Promise the form's\n * synchronous validation pass cannot wait on, so it is rejected loudly rather\n * than silently passing.\n */\nexport function readSchemaIssues(\n schema: StandardSchemaV1,\n values: EntityDraft,\n translateIssue: (message: string, field: string | undefined) => string,\n): { fields: Record<string, string>; form?: string } {\n const result = schema['~standard'].validate(values);\n\n if (result instanceof Promise) {\n throw new EntifixLogicError(\n 'useEntityForm cannot run an async schema: its validation pass is ' +\n 'synchronous. Move the asynchronous rule into the `validate` option.',\n );\n }\n if (!result.issues) return { fields: {} };\n\n const fields: Record<string, string> = {};\n let form: string | undefined;\n\n for (const issue of result.issues) {\n const name = issueFieldName(issue);\n const message = translateIssue(issue.message, name);\n\n if (name === undefined) form ??= message;\n else fields[name] ??= message;\n }\n\n return { fields, form };\n}\n\n/** Everything {@link composeEntityFormErrors} needs to judge one draft. */\nexport interface ComposeEntityFormErrorsOptions {\n descriptors: readonly EntityFieldDescriptor[];\n values: EntityDraft;\n messages: EntityDraftMessages;\n /** How a metadata message names its field. Defaults to the untranslated `label`. */\n labelFor?: EntityFieldLabeler;\n schema?: StandardSchemaV1;\n translateIssue: (message: string, field: string | undefined) => string;\n validate?: (values: EntityDraft) => Record<string, string>;\n}\n\n/**\n * The single composition point for a draft's errors: metadata rules first, then\n * the entity's schema, then the caller's `validate` callback. Later sources win\n * on conflict, so a caller can always override a message it disagrees with.\n */\nexport function composeEntityFormErrors({\n descriptors,\n values,\n messages,\n labelFor,\n schema,\n translateIssue,\n validate,\n}: ComposeEntityFormErrorsOptions): {\n fields: Record<string, string>;\n form?: string;\n} {\n const metadata = validateEntityDraft(descriptors, values, messages, labelFor);\n const schemaIssues = schema\n ? readSchemaIssues(schema, values, translateIssue)\n : { fields: {}, form: undefined };\n\n return {\n fields: { ...metadata, ...schemaIssues.fields, ...validate?.(values) },\n form: schemaIssues.form,\n };\n}\n\n/**\n * What a caller who supplies no {@link ValidationMessages} gets: the catalog key\n * itself, with the field appended.\n *\n * Honest rather than English — there is no catalog in this package to resolve\n * against — and visible enough in a form that a host notices it has wired no\n * copy. It sits beside the validator rather than in `.types.ts`, which this\n * repository keeps type-only.\n */\nexport const DEFAULT_VALIDATION_MESSAGES: EntityDraftMessages = {\n required: field => `validation.required:${field}`,\n number: field => `validation.number:${field}`,\n date: field => `validation.date:${field}`,\n option: field => `validation.option:${field}`,\n minItems: (field, count) => `validation.minItems:${field}:${count}`,\n maxItems: (field, count) => `validation.maxItems:${field}:${count}`,\n notBefore: (field, other) => `validation.notBefore:${field}:${other}`,\n locale: (field, locale) => `validation.locale:${field}:${locale}`,\n};\n"],"names":["describeChildColumns","editableChildColumns","EntifixLogicError","EntityCollectionLink","EntityLink","isLocalizedText","isRowDraftArray","joinFieldPath","locales","readDraftString","readLocalizedDraft","readRowDrafts","ROW_KEY","rowFieldPath","seededRowKey","isNeverEdited","descriptor","readonly","hasCheckableFormat","type","seedFieldValue","entity","seedRowDrafts","seedLocalizedDraft","seedScalarValue","raw","name","text","Object","fromEntries","map","locale","id","String","ids","join","Date","toISOString","slice","Array","isArray","childType","undefined","columns","row","index","draft","column","seedEntityDraft","descriptors","restoreEntityDraft","seed","persisted","restored","value","readFieldErrors","fieldMeta","errors","meta","entries","message","find","entry","labelOf","label","validateEntityDraft","values","messages","labelFor","formatMessage","assign","validateComposition","localizedMessage","required","trim","counted","isCountedList","itemCountMessage","countItems","orderMessage","missing","filter","length","first","split","item","count","minItems","maxItems","notBefore","earlier","other","start","Number","isNaN","getTime","number","date","enumValues","includes","option","rows","forEach","path","issueFieldName","issue","segments","segment","key","readSchemaIssues","schema","translateIssue","result","validate","Promise","issues","fields","form","composeEntityFormErrors","metadata","schemaIssues","DEFAULT_VALIDATION_MESSAGES","field"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-entity-link-source.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-entity-link-source/use-entity-link-source.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,gBAAgB,EAGtB,MAAM,eAAe,CAAC;AAWvB,OAAO,KAAK,EACV,sBAAsB,EACtB,0BAA0B,EAC3B,MAAM,mCAAmC,CAAC;AAgC3C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAClE,MAAM,EAAE,sBAAsB,CAAC,OAAO,EAAE,QAAQ,CAAC,EACjD,EACE,UAAU,EACV,UAAU,EACV,cAAc,GACf,EAAE,0BAA0B,CAAC,OAAO,CAAC,GACrC,gBAAgB,CAAC,OAAO,CAAC,CAuJ3B"}
1
+ {"version":3,"file":"use-entity-link-source.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-entity-link-source/use-entity-link-source.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,gBAAgB,EAGtB,MAAM,eAAe,CAAC;AAWvB,OAAO,KAAK,EACV,sBAAsB,EACtB,0BAA0B,EAC3B,MAAM,mCAAmC,CAAC;AA2C3C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAClE,MAAM,EAAE,sBAAsB,CAAC,OAAO,EAAE,QAAQ,CAAC,EACjD,EACE,UAAU,EACV,UAAU,EACV,cAAc,GACf,EAAE,0BAA0B,CAAC,OAAO,CAAC,GACrC,gBAAgB,CAAC,OAAO,CAAC,CAuJ3B"}
@@ -19,6 +19,14 @@ const DEFAULT_DEBOUNCE_MS = 250;
19
19
  * break the developer's build than to lie to them.
20
20
  */ function assertSearchable(config, searchProperty) {
21
21
  const target = describeEntityColumns(config.entityConstructor).find((column)=>column.name === searchProperty);
22
+ if ((target == null ? void 0 : target.type) === 'localized') {
23
+ // Queried one locale at a time, and this hook holds no locale: the bare
24
+ // member is what the service would refuse.
25
+ throw new EntifixLogicError(`Cannot search ${config.entityConstructor.name} by "${searchProperty}": ` + 'the member is localized, and a quick search has no locale to search ' + 'it in. Point the relation at a string member with `linkSearchProperty`.', undefined, {
26
+ entity: config.entityConstructor.name,
27
+ searchProperty
28
+ });
29
+ }
22
30
  if ((target == null ? void 0 : target.filterable) === true) return;
23
31
  throw new EntifixLogicError(`Cannot search ${config.entityConstructor.name} by "${searchProperty}": ` + 'the member is not filterable, so the service would reject the query. ' + 'Declare it `filterable` on the target entity, or point the relation at ' + 'another member with `linkSearchProperty`.', undefined, {
24
32
  entity: config.entityConstructor.name,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/use-entity-link-source/use-entity-link-source.ts"],"sourcesContent":["'use client';\n\nimport { EntityIdTag, EntityLoadRequestTag } from '@entifix/business';\nimport {\n describeEntityColumns,\n type EntifixError,\n EntifixLogicError,\n type Entity,\n type EntityLinkSource,\n type EntityLoadRequest,\n type EntityPage,\n} from '@entifix/core';\nimport { useQuery } from '@tanstack/react-query';\nimport { Context, Effect } from 'effect';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { combineFilterGroups } from '../../query/combine-filtering.js';\nimport {\n entityQueryKey,\n entityQueryScope,\n} from '../../query/entity-query-key.js';\nimport { useDataLoading } from '../use-data-loading/index.js';\nimport type {\n EntityLinkSourceConfig,\n UseEntityLinkSourceOptions,\n} from './use-entity-link-source.types.js';\n\nconst DEFAULT_QUICK_PAGE_SIZE = 10;\nconst DEFAULT_DEBOUNCE_MS = 250;\n\n/**\n * Fails loudly on a search property the service will reject.\n *\n * A member's `filterable` metadata is also the server-side filter allowlist, so a\n * quick search over a member that lacks it answers `400` — as an empty\n * suggestion list, which reads to the user as \"there are no brands\". Better to\n * break the developer's build than to lie to them.\n */\nfunction assertSearchable<TTarget extends Entity>(\n config: EntityLinkSourceConfig<TTarget, unknown>,\n searchProperty: string,\n): void {\n const target = describeEntityColumns(config.entityConstructor).find(\n column => column.name === searchProperty,\n );\n if (target?.filterable === true) return;\n\n throw new EntifixLogicError(\n `Cannot search ${config.entityConstructor.name} by \"${searchProperty}\": ` +\n 'the member is not filterable, so the service would reject the query. ' +\n 'Declare it `filterable` on the target entity, or point the relation at ' +\n 'another member with `linkSearchProperty`.',\n undefined,\n { entity: config.entityConstructor.name, searchProperty },\n );\n}\n\n/**\n * Produces the {@link EntityLinkSource} a link editor runs on: the quick\n * type-ahead, the browse list, and the label of whatever key is currently held.\n *\n * This is the data half of the link mechanism. It exists as a hook — rather than\n * inside the input — because `@entifix/react-controls` may not import this package\n * (both are T3, and the tier register declares no sideways edge between them), so\n * the two meet at the framework-free port declared in `@entifix/core`. A form\n * wrapper calls it once per relation and hands the results to `EntityForm`'s\n * `linkSources`.\n *\n * What may be picked is never decided here: the caller's `loadUc` (and optional\n * `baseFiltering`) is what restricts the set, so a domain rule about assignable\n * targets lives in a use-case, not in the UI.\n *\n * Three requests, each held back until it means something: the quick search runs\n * a small page and re-runs on a debounced term; the browse list does not fetch at\n * all until the dialog opens; and the label is only looked up when the caller has\n * an id but no instance — a draft restored from storage — because a picked target\n * already knows its own name.\n */\nexport function useEntityLinkSource<TTarget extends Entity, TContext>(\n config: EntityLinkSourceConfig<TTarget, TContext>,\n {\n descriptor,\n selectedId,\n selectedEntity,\n }: UseEntityLinkSourceOptions<TTarget>,\n): EntityLinkSource<TTarget> {\n const {\n entityConstructor,\n loadUc,\n getUc,\n ctx,\n baseFiltering,\n quickPageSize = DEFAULT_QUICK_PAGE_SIZE,\n debounceMs = DEFAULT_DEBOUNCE_MS,\n } = config;\n\n const searchProperty = descriptor.linkSearchProperty;\n assertSearchable(\n config as unknown as EntityLinkSourceConfig<TTarget, unknown>,\n searchProperty,\n );\n\n // Callers rebuild `loadUc`/`getUc`/`ctx` inline on every render, so the latest\n // are held in refs and kept out of the query keys — keying on their identity\n // would refetch on every render. Same reason and same shape as `useDataLoading`.\n const effects = useRef({ loadUc, getUc, ctx });\n useEffect(() => {\n effects.current = { loadUc, getUc, ctx };\n });\n\n const [term, setTerm] = useState('');\n const [debouncedTerm, setDebouncedTerm] = useState('');\n const [isOpen, setOpen] = useState(false);\n\n // Typing is not a request per keystroke: the term settles first, and only the\n // settled value reaches the service.\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedTerm(term), debounceMs);\n return () => clearTimeout(timer);\n }, [term, debounceMs]);\n\n const quickRequest = useMemo<EntityLoadRequest<TTarget>>(() => {\n const filtering = combineFilterGroups<TTarget>(\n baseFiltering,\n debouncedTerm === ''\n ? undefined\n : {\n operator: 'and',\n values: [\n {\n property: searchProperty as keyof TTarget,\n operator: 'like',\n value: debouncedTerm,\n },\n ],\n },\n );\n\n const request: EntityLoadRequest<TTarget> = {\n page: 1,\n pageSize: quickPageSize,\n };\n if (filtering) request.filtering = [filtering];\n return request;\n }, [baseFiltering, debouncedTerm, quickPageSize, searchProperty]);\n\n const quickQuery = useQuery<EntityPage<TTarget>, EntifixError>({\n queryKey: [\n ...entityQueryKey(entityConstructor, quickRequest),\n 'link-quick',\n ],\n queryFn: () =>\n Effect.runPromise(\n Effect.provide(\n effects.current.loadUc,\n effects.current.ctx.pipe(\n Context.add(\n EntityLoadRequestTag,\n // The tag carries no generic, so the entity-typed request crosses\n // it — the same cast `loadUCFactory` makes reading it back.\n quickRequest as unknown as EntityLoadRequest,\n ),\n ),\n ),\n ),\n });\n\n const browse = useDataLoading<TTarget, TContext>({\n uc: loadUc,\n ctx,\n queryKey: entityQueryScope(entityConstructor),\n baseFiltering,\n enabled: isOpen,\n });\n\n const needsLabel =\n selectedEntity === undefined && selectedId != null && getUc !== undefined;\n\n const labelQuery = useQuery<TTarget, EntifixError>({\n queryKey: [\n ...entityQueryScope(entityConstructor),\n 'link-label',\n String(selectedId),\n ],\n enabled: needsLabel,\n queryFn: () =>\n Effect.runPromise(\n Effect.provide(\n // Guarded by `enabled`: the query only runs when both are present.\n effects.current.getUc as NonNullable<typeof getUc>,\n effects.current.ctx.pipe(Context.add(EntityIdTag, selectedId)),\n ),\n ),\n });\n\n // A blank label is as useless as a missing one, so both fall back to the key —\n // an unnamed row must still be distinguishable from the row above it.\n const labelOf = useCallback(\n (target: TTarget) => {\n const label = (target as Record<string, unknown>)[\n descriptor.linkLabelProperty\n ];\n return label == null || label === ''\n ? String(target.id ?? '')\n : String(label);\n },\n [descriptor.linkLabelProperty],\n );\n\n const resolved = selectedEntity ?? labelQuery.data;\n\n return {\n entityConstructor,\n labelOf,\n selected: {\n label: resolved === undefined ? undefined : labelOf(resolved),\n // The whole target, not only its name. The lookup above already fetched\n // it, and a form restoring a draft has nothing else to rebuild an\n // `embedded` relation from — the draft kept the id and lost the instance.\n entity: resolved,\n isLoading: needsLabel && labelQuery.isFetching,\n },\n quick: {\n term,\n setTerm,\n options: quickQuery.data?.items ?? [],\n isLoading: quickQuery.isFetching,\n error: quickQuery.error ?? undefined,\n },\n browse: {\n ...browse,\n isOpen,\n open: () => setOpen(true),\n close: () => setOpen(false),\n },\n };\n}\n"],"names":["EntityIdTag","EntityLoadRequestTag","describeEntityColumns","EntifixLogicError","useQuery","Context","Effect","useCallback","useEffect","useMemo","useRef","useState","combineFilterGroups","entityQueryKey","entityQueryScope","useDataLoading","DEFAULT_QUICK_PAGE_SIZE","DEFAULT_DEBOUNCE_MS","assertSearchable","config","searchProperty","target","entityConstructor","find","column","name","filterable","undefined","entity","useEntityLinkSource","descriptor","selectedId","selectedEntity","quickQuery","loadUc","getUc","ctx","baseFiltering","quickPageSize","debounceMs","linkSearchProperty","effects","current","term","setTerm","debouncedTerm","setDebouncedTerm","isOpen","setOpen","timer","setTimeout","clearTimeout","quickRequest","filtering","operator","values","property","value","request","page","pageSize","queryKey","queryFn","runPromise","provide","pipe","add","browse","uc","enabled","needsLabel","labelQuery","String","labelOf","label","linkLabelProperty","id","resolved","data","selected","isLoading","isFetching","quick","options","items","error","open","close"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/hooks/use-entity-link-source/use-entity-link-source.ts"],"sourcesContent":["'use client';\n\nimport { EntityIdTag, EntityLoadRequestTag } from '@entifix/business';\nimport {\n describeEntityColumns,\n type EntifixError,\n EntifixLogicError,\n type Entity,\n type EntityLinkSource,\n type EntityLoadRequest,\n type EntityPage,\n} from '@entifix/core';\nimport { useQuery } from '@tanstack/react-query';\nimport { Context, Effect } from 'effect';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { combineFilterGroups } from '../../query/combine-filtering.js';\nimport {\n entityQueryKey,\n entityQueryScope,\n} from '../../query/entity-query-key.js';\nimport { useDataLoading } from '../use-data-loading/index.js';\nimport type {\n EntityLinkSourceConfig,\n UseEntityLinkSourceOptions,\n} from './use-entity-link-source.types.js';\n\nconst DEFAULT_QUICK_PAGE_SIZE = 10;\nconst DEFAULT_DEBOUNCE_MS = 250;\n\n/**\n * Fails loudly on a search property the service will reject.\n *\n * A member's `filterable` metadata is also the server-side filter allowlist, so a\n * quick search over a member that lacks it answers `400` — as an empty\n * suggestion list, which reads to the user as \"there are no brands\". Better to\n * break the developer's build than to lie to them.\n */\nfunction assertSearchable<TTarget extends Entity>(\n config: EntityLinkSourceConfig<TTarget, unknown>,\n searchProperty: string,\n): void {\n const target = describeEntityColumns(config.entityConstructor).find(\n column => column.name === searchProperty,\n );\n if (target?.type === 'localized') {\n // Queried one locale at a time, and this hook holds no locale: the bare\n // member is what the service would refuse.\n throw new EntifixLogicError(\n `Cannot search ${config.entityConstructor.name} by \"${searchProperty}\": ` +\n 'the member is localized, and a quick search has no locale to search ' +\n 'it in. Point the relation at a string member with `linkSearchProperty`.',\n undefined,\n { entity: config.entityConstructor.name, searchProperty },\n );\n }\n if (target?.filterable === true) return;\n\n throw new EntifixLogicError(\n `Cannot search ${config.entityConstructor.name} by \"${searchProperty}\": ` +\n 'the member is not filterable, so the service would reject the query. ' +\n 'Declare it `filterable` on the target entity, or point the relation at ' +\n 'another member with `linkSearchProperty`.',\n undefined,\n { entity: config.entityConstructor.name, searchProperty },\n );\n}\n\n/**\n * Produces the {@link EntityLinkSource} a link editor runs on: the quick\n * type-ahead, the browse list, and the label of whatever key is currently held.\n *\n * This is the data half of the link mechanism. It exists as a hook — rather than\n * inside the input — because `@entifix/react-controls` may not import this package\n * (both are T3, and the tier register declares no sideways edge between them), so\n * the two meet at the framework-free port declared in `@entifix/core`. A form\n * wrapper calls it once per relation and hands the results to `EntityForm`'s\n * `linkSources`.\n *\n * What may be picked is never decided here: the caller's `loadUc` (and optional\n * `baseFiltering`) is what restricts the set, so a domain rule about assignable\n * targets lives in a use-case, not in the UI.\n *\n * Three requests, each held back until it means something: the quick search runs\n * a small page and re-runs on a debounced term; the browse list does not fetch at\n * all until the dialog opens; and the label is only looked up when the caller has\n * an id but no instance — a draft restored from storage — because a picked target\n * already knows its own name.\n */\nexport function useEntityLinkSource<TTarget extends Entity, TContext>(\n config: EntityLinkSourceConfig<TTarget, TContext>,\n {\n descriptor,\n selectedId,\n selectedEntity,\n }: UseEntityLinkSourceOptions<TTarget>,\n): EntityLinkSource<TTarget> {\n const {\n entityConstructor,\n loadUc,\n getUc,\n ctx,\n baseFiltering,\n quickPageSize = DEFAULT_QUICK_PAGE_SIZE,\n debounceMs = DEFAULT_DEBOUNCE_MS,\n } = config;\n\n const searchProperty = descriptor.linkSearchProperty;\n assertSearchable(\n config as unknown as EntityLinkSourceConfig<TTarget, unknown>,\n searchProperty,\n );\n\n // Callers rebuild `loadUc`/`getUc`/`ctx` inline on every render, so the latest\n // are held in refs and kept out of the query keys — keying on their identity\n // would refetch on every render. Same reason and same shape as `useDataLoading`.\n const effects = useRef({ loadUc, getUc, ctx });\n useEffect(() => {\n effects.current = { loadUc, getUc, ctx };\n });\n\n const [term, setTerm] = useState('');\n const [debouncedTerm, setDebouncedTerm] = useState('');\n const [isOpen, setOpen] = useState(false);\n\n // Typing is not a request per keystroke: the term settles first, and only the\n // settled value reaches the service.\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedTerm(term), debounceMs);\n return () => clearTimeout(timer);\n }, [term, debounceMs]);\n\n const quickRequest = useMemo<EntityLoadRequest<TTarget>>(() => {\n const filtering = combineFilterGroups<TTarget>(\n baseFiltering,\n debouncedTerm === ''\n ? undefined\n : {\n operator: 'and',\n values: [\n {\n property: searchProperty as keyof TTarget,\n operator: 'like',\n value: debouncedTerm,\n },\n ],\n },\n );\n\n const request: EntityLoadRequest<TTarget> = {\n page: 1,\n pageSize: quickPageSize,\n };\n if (filtering) request.filtering = [filtering];\n return request;\n }, [baseFiltering, debouncedTerm, quickPageSize, searchProperty]);\n\n const quickQuery = useQuery<EntityPage<TTarget>, EntifixError>({\n queryKey: [\n ...entityQueryKey(entityConstructor, quickRequest),\n 'link-quick',\n ],\n queryFn: () =>\n Effect.runPromise(\n Effect.provide(\n effects.current.loadUc,\n effects.current.ctx.pipe(\n Context.add(\n EntityLoadRequestTag,\n // The tag carries no generic, so the entity-typed request crosses\n // it — the same cast `loadUCFactory` makes reading it back.\n quickRequest as unknown as EntityLoadRequest,\n ),\n ),\n ),\n ),\n });\n\n const browse = useDataLoading<TTarget, TContext>({\n uc: loadUc,\n ctx,\n queryKey: entityQueryScope(entityConstructor),\n baseFiltering,\n enabled: isOpen,\n });\n\n const needsLabel =\n selectedEntity === undefined && selectedId != null && getUc !== undefined;\n\n const labelQuery = useQuery<TTarget, EntifixError>({\n queryKey: [\n ...entityQueryScope(entityConstructor),\n 'link-label',\n String(selectedId),\n ],\n enabled: needsLabel,\n queryFn: () =>\n Effect.runPromise(\n Effect.provide(\n // Guarded by `enabled`: the query only runs when both are present.\n effects.current.getUc as NonNullable<typeof getUc>,\n effects.current.ctx.pipe(Context.add(EntityIdTag, selectedId)),\n ),\n ),\n });\n\n // A blank label is as useless as a missing one, so both fall back to the key —\n // an unnamed row must still be distinguishable from the row above it.\n const labelOf = useCallback(\n (target: TTarget) => {\n const label = (target as Record<string, unknown>)[\n descriptor.linkLabelProperty\n ];\n return label == null || label === ''\n ? String(target.id ?? '')\n : String(label);\n },\n [descriptor.linkLabelProperty],\n );\n\n const resolved = selectedEntity ?? labelQuery.data;\n\n return {\n entityConstructor,\n labelOf,\n selected: {\n label: resolved === undefined ? undefined : labelOf(resolved),\n // The whole target, not only its name. The lookup above already fetched\n // it, and a form restoring a draft has nothing else to rebuild an\n // `embedded` relation from — the draft kept the id and lost the instance.\n entity: resolved,\n isLoading: needsLabel && labelQuery.isFetching,\n },\n quick: {\n term,\n setTerm,\n options: quickQuery.data?.items ?? [],\n isLoading: quickQuery.isFetching,\n error: quickQuery.error ?? undefined,\n },\n browse: {\n ...browse,\n isOpen,\n open: () => setOpen(true),\n close: () => setOpen(false),\n },\n };\n}\n"],"names":["EntityIdTag","EntityLoadRequestTag","describeEntityColumns","EntifixLogicError","useQuery","Context","Effect","useCallback","useEffect","useMemo","useRef","useState","combineFilterGroups","entityQueryKey","entityQueryScope","useDataLoading","DEFAULT_QUICK_PAGE_SIZE","DEFAULT_DEBOUNCE_MS","assertSearchable","config","searchProperty","target","entityConstructor","find","column","name","type","undefined","entity","filterable","useEntityLinkSource","descriptor","selectedId","selectedEntity","quickQuery","loadUc","getUc","ctx","baseFiltering","quickPageSize","debounceMs","linkSearchProperty","effects","current","term","setTerm","debouncedTerm","setDebouncedTerm","isOpen","setOpen","timer","setTimeout","clearTimeout","quickRequest","filtering","operator","values","property","value","request","page","pageSize","queryKey","queryFn","runPromise","provide","pipe","add","browse","uc","enabled","needsLabel","labelQuery","String","labelOf","label","linkLabelProperty","id","resolved","data","selected","isLoading","isFetching","quick","options","items","error","open","close"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { readEventEnvelope } from '@entifix/core';
2
- import { Effect, Either } from 'effect';
2
+ import { Effect, Result } from 'effect';
3
3
  /**
4
4
  * The reactive stream's browser end: one `EventSource` over a same-origin URL.
5
5
  *
@@ -31,12 +31,12 @@ import { Effect, Either } from 'effect';
31
31
  // A frame this build cannot read is dropped rather than thrown: `onmessage`
32
32
  // runs inside the browser's event loop, where a throw reaches no caller and
33
33
  // would take the rest of this delivery's listeners with it.
34
- const parsed = Effect.runSync(Effect.either(Effect.flatMap(Effect.try(()=>JSON.parse(raw)), readEventEnvelope)));
35
- if (Either.isLeft(parsed)) {
34
+ const parsed = Effect.runSync(Effect.result(Effect.flatMap(Effect.try(()=>JSON.parse(raw)), readEventEnvelope)));
35
+ if (Result.isFailure(parsed)) {
36
36
  return;
37
37
  }
38
38
  for (const listener of listeners){
39
- listener(parsed.right);
39
+ listener(parsed.success);
40
40
  }
41
41
  };
42
42
  const open = ()=>{
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/reactive/event-source-channel.ts"],"sourcesContent":["import { type EntityChangeEvent, readEventEnvelope } from '@entifix/core';\nimport { Effect, Either } from 'effect';\n\nimport type {\n EntityChangeListener,\n ReactiveChannel,\n} from './reactive-channel.js';\n\n/**\n * The reactive stream's browser end: one `EventSource` over a same-origin URL.\n *\n * **Same-origin is the whole reason this is SSE and not a WebSocket.** `entifix_at`\n * is `httpOnly` and `sameSite: 'lax'`, and the `WebSocket` constructor accepts\n * no headers — so a socket would need a second class of bearer token handed to\n * client JavaScript, which is the one thing `httpOnly` protects against. An\n * `EventSource` against the app's own proxy path sends the cookie the way every\n * other call in the repo does (ADR 0036).\n *\n * What it also gets for free is the half #137 would otherwise hand-write:\n * `EventSource` reconnects on its own with backoff and re-sends `Last-Event-ID`.\n * The server accepts that header and ignores it — the stream is a hint that\n * something changed, and the record behind it is the truth.\n *\n * The connection is opened lazily on the first subscriber and closed with the\n * last, so a page that mounts no workspace holds none. One connection per URL,\n * not one per listener: SSE shares the origin's HTTP/1.1 connection budget.\n */\nexport function makeEventSourceReactiveChannel(url: string): ReactiveChannel {\n const listeners = new Set<EntityChangeListener>();\n const connectListeners = new Set<() => void>();\n let source: EventSource | undefined;\n // Tracked here rather than read off `source.readyState`, because it must\n // survive the close/reopen cycle the refcount drives: what a reconciling\n // consumer needs to know is \"has this channel been connected\", not \"is this\n // particular `EventSource` object open right now\".\n let connected = false;\n\n const deliver = (raw: string): void => {\n // A frame this build cannot read is dropped rather than thrown: `onmessage`\n // runs inside the browser's event loop, where a throw reaches no caller and\n // would take the rest of this delivery's listeners with it.\n const parsed = Effect.runSync(\n Effect.either(\n Effect.flatMap(\n Effect.try(() => JSON.parse(raw) as unknown),\n readEventEnvelope<EntityChangeEvent>,\n ),\n ),\n );\n if (Either.isLeft(parsed)) {\n return;\n }\n\n for (const listener of listeners) {\n listener(parsed.right);\n }\n };\n\n const open = (): void => {\n // Guarded because a server-rendered pass has no `EventSource` at all, and a\n // channel built at module scope must not throw there.\n if (source !== undefined || typeof EventSource === 'undefined') {\n return;\n }\n source = new EventSource(url);\n source.onmessage = message => {\n deliver(message.data as string);\n };\n // Fires on the first open *and* on every browser-driven reconnect, because\n // `EventSource` reconnects the same object rather than handing back a new\n // one — so nothing has to re-arm this.\n source.onopen = () => {\n connected = true;\n for (const listener of connectListeners) {\n listener();\n }\n };\n };\n\n const close = (): void => {\n source?.close();\n source = undefined;\n connected = false;\n };\n\n return {\n subscribe(listener) {\n listeners.add(listener);\n open();\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n close();\n }\n };\n },\n onConnect(listener) {\n connectListeners.add(listener);\n // ⚠️ The replay, and the reason this signal works at all. Registering does\n // *not* open the connection — subscribing is what does that — so a\n // consumer arriving after another one opened the stream would otherwise\n // wait for a network drop that may never come, and a restored pending set\n // would never be reconciled.\n if (connected) listener();\n return () => {\n connectListeners.delete(listener);\n };\n },\n };\n}\n"],"names":["readEventEnvelope","Effect","Either","makeEventSourceReactiveChannel","url","listeners","Set","connectListeners","source","connected","deliver","raw","parsed","runSync","either","flatMap","try","JSON","parse","isLeft","listener","right","open","undefined","EventSource","onmessage","message","data","onopen","close","subscribe","add","delete","size","onConnect"],"mappings":"AAAA,SAAiCA,iBAAiB,QAAQ,gBAAgB;AAC1E,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAOxC;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,+BAA+BC,GAAW;IACxD,MAAMC,YAAY,IAAIC;IACtB,MAAMC,mBAAmB,IAAID;IAC7B,IAAIE;IACJ,yEAAyE;IACzE,yEAAyE;IACzE,4EAA4E;IAC5E,mDAAmD;IACnD,IAAIC,YAAY;IAEhB,MAAMC,UAAU,CAACC;QACf,4EAA4E;QAC5E,4EAA4E;QAC5E,4DAA4D;QAC5D,MAAMC,SAASX,OAAOY,OAAO,CAC3BZ,OAAOa,MAAM,CACXb,OAAOc,OAAO,CACZd,OAAOe,GAAG,CAAC,IAAMC,KAAKC,KAAK,CAACP,OAC5BX;QAIN,IAAIE,OAAOiB,MAAM,CAACP,SAAS;YACzB;QACF;QAEA,KAAK,MAAMQ,YAAYf,UAAW;YAChCe,SAASR,OAAOS,KAAK;QACvB;IACF;IAEA,MAAMC,OAAO;QACX,4EAA4E;QAC5E,sDAAsD;QACtD,IAAId,WAAWe,aAAa,OAAOC,gBAAgB,aAAa;YAC9D;QACF;QACAhB,SAAS,IAAIgB,YAAYpB;QACzBI,OAAOiB,SAAS,GAAGC,CAAAA;YACjBhB,QAAQgB,QAAQC,IAAI;QACtB;QACA,2EAA2E;QAC3E,0EAA0E;QAC1E,uCAAuC;QACvCnB,OAAOoB,MAAM,GAAG;YACdnB,YAAY;YACZ,KAAK,MAAMW,YAAYb,iBAAkB;gBACvCa;YACF;QACF;IACF;IAEA,MAAMS,QAAQ;QACZrB,0BAAAA,OAAQqB,KAAK;QACbrB,SAASe;QACTd,YAAY;IACd;IAEA,OAAO;QACLqB,WAAUV,QAAQ;YAChBf,UAAU0B,GAAG,CAACX;YACdE;YACA,OAAO;gBACLjB,UAAU2B,MAAM,CAACZ;gBACjB,IAAIf,UAAU4B,IAAI,KAAK,GAAG;oBACxBJ;gBACF;YACF;QACF;QACAK,WAAUd,QAAQ;YAChBb,iBAAiBwB,GAAG,CAACX;YACrB,2EAA2E;YAC3E,mEAAmE;YACnE,wEAAwE;YACxE,0EAA0E;YAC1E,6BAA6B;YAC7B,IAAIX,WAAWW;YACf,OAAO;gBACLb,iBAAiByB,MAAM,CAACZ;YAC1B;QACF;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/reactive/event-source-channel.ts"],"sourcesContent":["import { type EntityChangeEvent, readEventEnvelope } from '@entifix/core';\nimport { Effect, Result } from 'effect';\n\nimport type {\n EntityChangeListener,\n ReactiveChannel,\n} from './reactive-channel.js';\n\n/**\n * The reactive stream's browser end: one `EventSource` over a same-origin URL.\n *\n * **Same-origin is the whole reason this is SSE and not a WebSocket.** `entifix_at`\n * is `httpOnly` and `sameSite: 'lax'`, and the `WebSocket` constructor accepts\n * no headers — so a socket would need a second class of bearer token handed to\n * client JavaScript, which is the one thing `httpOnly` protects against. An\n * `EventSource` against the app's own proxy path sends the cookie the way every\n * other call in the repo does (ADR 0036).\n *\n * What it also gets for free is the half #137 would otherwise hand-write:\n * `EventSource` reconnects on its own with backoff and re-sends `Last-Event-ID`.\n * The server accepts that header and ignores it — the stream is a hint that\n * something changed, and the record behind it is the truth.\n *\n * The connection is opened lazily on the first subscriber and closed with the\n * last, so a page that mounts no workspace holds none. One connection per URL,\n * not one per listener: SSE shares the origin's HTTP/1.1 connection budget.\n */\nexport function makeEventSourceReactiveChannel(url: string): ReactiveChannel {\n const listeners = new Set<EntityChangeListener>();\n const connectListeners = new Set<() => void>();\n let source: EventSource | undefined;\n // Tracked here rather than read off `source.readyState`, because it must\n // survive the close/reopen cycle the refcount drives: what a reconciling\n // consumer needs to know is \"has this channel been connected\", not \"is this\n // particular `EventSource` object open right now\".\n let connected = false;\n\n const deliver = (raw: string): void => {\n // A frame this build cannot read is dropped rather than thrown: `onmessage`\n // runs inside the browser's event loop, where a throw reaches no caller and\n // would take the rest of this delivery's listeners with it.\n const parsed = Effect.runSync(\n Effect.result(\n Effect.flatMap(\n Effect.try(() => JSON.parse(raw) as unknown),\n readEventEnvelope<EntityChangeEvent>,\n ),\n ),\n );\n if (Result.isFailure(parsed)) {\n return;\n }\n\n for (const listener of listeners) {\n listener(parsed.success);\n }\n };\n\n const open = (): void => {\n // Guarded because a server-rendered pass has no `EventSource` at all, and a\n // channel built at module scope must not throw there.\n if (source !== undefined || typeof EventSource === 'undefined') {\n return;\n }\n source = new EventSource(url);\n source.onmessage = message => {\n deliver(message.data as string);\n };\n // Fires on the first open *and* on every browser-driven reconnect, because\n // `EventSource` reconnects the same object rather than handing back a new\n // one — so nothing has to re-arm this.\n source.onopen = () => {\n connected = true;\n for (const listener of connectListeners) {\n listener();\n }\n };\n };\n\n const close = (): void => {\n source?.close();\n source = undefined;\n connected = false;\n };\n\n return {\n subscribe(listener) {\n listeners.add(listener);\n open();\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n close();\n }\n };\n },\n onConnect(listener) {\n connectListeners.add(listener);\n // ⚠️ The replay, and the reason this signal works at all. Registering does\n // *not* open the connection — subscribing is what does that — so a\n // consumer arriving after another one opened the stream would otherwise\n // wait for a network drop that may never come, and a restored pending set\n // would never be reconciled.\n if (connected) listener();\n return () => {\n connectListeners.delete(listener);\n };\n },\n };\n}\n"],"names":["readEventEnvelope","Effect","Result","makeEventSourceReactiveChannel","url","listeners","Set","connectListeners","source","connected","deliver","raw","parsed","runSync","result","flatMap","try","JSON","parse","isFailure","listener","success","open","undefined","EventSource","onmessage","message","data","onopen","close","subscribe","add","delete","size","onConnect"],"mappings":"AAAA,SAAiCA,iBAAiB,QAAQ,gBAAgB;AAC1E,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAOxC;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,+BAA+BC,GAAW;IACxD,MAAMC,YAAY,IAAIC;IACtB,MAAMC,mBAAmB,IAAID;IAC7B,IAAIE;IACJ,yEAAyE;IACzE,yEAAyE;IACzE,4EAA4E;IAC5E,mDAAmD;IACnD,IAAIC,YAAY;IAEhB,MAAMC,UAAU,CAACC;QACf,4EAA4E;QAC5E,4EAA4E;QAC5E,4DAA4D;QAC5D,MAAMC,SAASX,OAAOY,OAAO,CAC3BZ,OAAOa,MAAM,CACXb,OAAOc,OAAO,CACZd,OAAOe,GAAG,CAAC,IAAMC,KAAKC,KAAK,CAACP,OAC5BX;QAIN,IAAIE,OAAOiB,SAAS,CAACP,SAAS;YAC5B;QACF;QAEA,KAAK,MAAMQ,YAAYf,UAAW;YAChCe,SAASR,OAAOS,OAAO;QACzB;IACF;IAEA,MAAMC,OAAO;QACX,4EAA4E;QAC5E,sDAAsD;QACtD,IAAId,WAAWe,aAAa,OAAOC,gBAAgB,aAAa;YAC9D;QACF;QACAhB,SAAS,IAAIgB,YAAYpB;QACzBI,OAAOiB,SAAS,GAAGC,CAAAA;YACjBhB,QAAQgB,QAAQC,IAAI;QACtB;QACA,2EAA2E;QAC3E,0EAA0E;QAC1E,uCAAuC;QACvCnB,OAAOoB,MAAM,GAAG;YACdnB,YAAY;YACZ,KAAK,MAAMW,YAAYb,iBAAkB;gBACvCa;YACF;QACF;IACF;IAEA,MAAMS,QAAQ;QACZrB,0BAAAA,OAAQqB,KAAK;QACbrB,SAASe;QACTd,YAAY;IACd;IAEA,OAAO;QACLqB,WAAUV,QAAQ;YAChBf,UAAU0B,GAAG,CAACX;YACdE;YACA,OAAO;gBACLjB,UAAU2B,MAAM,CAACZ;gBACjB,IAAIf,UAAU4B,IAAI,KAAK,GAAG;oBACxBJ;gBACF;YACF;QACF;QACAK,WAAUd,QAAQ;YAChBb,iBAAiBwB,GAAG,CAACX;YACrB,2EAA2E;YAC3E,mEAAmE;YACnE,wEAAwE;YACxE,0EAA0E;YAC1E,6BAA6B;YAC7B,IAAIX,WAAWW;YACf,OAAO;gBACLb,iBAAiByB,MAAM,CAACZ;YAC1B;QACF;IACF;AACF"}
@@ -40,7 +40,7 @@ export interface ReactiveChannel {
40
40
  */
41
41
  onConnect(listener: () => void): () => void;
42
42
  }
43
- declare const ReactiveChannelTag_base: Context.TagClass<ReactiveChannelTag, "ReactiveChannel", ReactiveChannel>;
43
+ declare const ReactiveChannelTag_base: Context.ServiceClass<ReactiveChannelTag, "ReactiveChannel", ReactiveChannel>;
44
44
  export declare class ReactiveChannelTag extends ReactiveChannelTag_base {
45
45
  }
46
46
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"reactive-channel.d.ts","sourceRoot":"","sources":["../../src/reactive/reactive-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,YAAY,EAAE,iBAAiB,EAAE,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,MAAM,oBAAoB,GAAG,CACjC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,KAClC,IAAI,CAAC;AAEV;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAC;IACtD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7C;;AAED,qBAAa,kBAAmB,SAAQ,uBAGrC;CAAG;AAEN;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,EAAE,eAGjC,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6EAA6E;IAC7E,IAAI,CAAC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAAC;IAClD;;;;;;OAMG;IACH,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,IAAI,uBAAuB,CAgCrE"}
1
+ {"version":3,"file":"reactive-channel.d.ts","sourceRoot":"","sources":["../../src/reactive/reactive-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,YAAY,EAAE,iBAAiB,EAAE,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,MAAM,oBAAoB,GAAG,CACjC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,KAClC,IAAI,CAAC;AAEV;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAC;IACtD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7C;;AAED,qBAAa,kBAAmB,SAAQ,uBAGlB;CAAG;AAEzB;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,EAAE,eAGjC,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6EAA6E;IAC7E,IAAI,CAAC,KAAK,EAAE,WAAW,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAAC;IAClD;;;;;;OAMG;IACH,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,IAAI,uBAAuB,CAgCrE"}
@@ -1,5 +1,5 @@
1
1
  import { Context } from 'effect';
2
- export class ReactiveChannelTag extends Context.Tag('ReactiveChannel')() {
2
+ export class ReactiveChannelTag extends Context.Service()('ReactiveChannel') {
3
3
  }
4
4
  /**
5
5
  * A channel that never emits — the default until a transport is provided.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/reactive/reactive-channel.ts"],"sourcesContent":["import type { DomainEvent, EntityChangeEvent } from '@entifix/core';\nimport { Context } from 'effect';\n\nexport type { EntityChangeEvent };\n\n/**\n * A listener on the reactive stream.\n *\n * It receives the whole message, not just the payload: the transaction id, the\n * emission time and the sequence a consumer needs are `meta.event.correlationId`,\n * `.at` and `.id`, and duplicating them onto {@link EntityChangeEvent} is how\n * two sources of one fact drift (ADR 0036). `#137` settles an optimistic write\n * on the correlation id; `useReactiveInvalidation` reads only `data.entity`.\n */\nexport type EntityChangeListener = (\n event: DomainEvent<EntityChangeEvent>,\n) => void;\n\n/**\n * The reactive-updates port. Framework-free on purpose (like the OTel tooling):\n * the transport is swappable, and the React side wires `subscribe` to the query\n * client. `subscribe` returns its own unsubscribe.\n */\nexport interface ReactiveChannel {\n subscribe(listener: EntityChangeListener): () => void;\n /**\n * Runs whenever the stream is (re)connected — and **immediately if it is\n * already connected when the listener registers**.\n *\n * The stream does not replay, by design (ADR 0036), so a consumer that missed\n * an outcome while disconnected has to go and ask. This is the signal that\n * tells it when to: `#137` re-queries every pending transaction id here.\n *\n * ⚠️ **The replay is the mechanism, not a convenience.** The channel is\n * refcounted and opened by its *first* subscriber, and\n * `useReactiveInvalidation` typically is that subscriber. A consumer mounting\n * after it — which depends on nothing more than component-tree ordering —\n * would otherwise register for an open that had already happened, and\n * reconcile nothing until the next real network drop. On a page load with no\n * disconnect, a pending set restored from the previous session would never be\n * reconciled at all, which is the case this exists for.\n *\n * Required rather than optional: an optional member on a port is how one\n * implementation quietly stops honouring it while every consumer compiles.\n */\n onConnect(listener: () => void): () => void;\n}\n\nexport class ReactiveChannelTag extends Context.Tag('ReactiveChannel')<\n ReactiveChannelTag,\n ReactiveChannel\n>() {}\n\n/**\n * A channel that never emits — the default until a transport is provided.\n *\n * It never connects either, so `onConnect` registers nothing: a consumer of this\n * channel reconciles never, which is the honest behaviour for a transport that\n * is not there.\n */\nexport const NoopReactiveChannel: ReactiveChannel = {\n subscribe: () => () => undefined,\n onConnect: () => () => undefined,\n};\n\nexport interface InMemoryReactiveChannel extends ReactiveChannel {\n /** Push an event to every current subscriber (drives tests and the mock). */\n emit(event: DomainEvent<EntityChangeEvent>): void;\n /**\n * Announce a (re)connection, so a spec can drive the reconciliation path.\n *\n * Also flips this channel to \"connected\", which is what makes a listener\n * registered afterwards fire immediately — the behaviour the real transport\n * has and the one most likely to be got wrong.\n */\n connect(): void;\n}\n\n/**\n * A synchronous in-memory channel: `emit` fans out to every live subscriber.\n *\n * It backs specs. It is no longer what the workspace runs on — that is\n * `makeEventSourceReactiveChannel`, and until ADR 0036 landed a transport this\n * one was mounted in its place, emitting nothing, so a reader of the workspace\n * could not tell \"the transport is missing\" from \"nothing changed\".\n */\nexport function makeInMemoryReactiveChannel(): InMemoryReactiveChannel {\n const listeners = new Set<EntityChangeListener>();\n const connectListeners = new Set<() => void>();\n let connected = false;\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n onConnect(listener) {\n connectListeners.add(listener);\n // Mirrors the real transport: a listener arriving after the connection is\n // already up must not wait for the next drop to hear about it.\n if (connected) listener();\n return () => {\n connectListeners.delete(listener);\n };\n },\n connect() {\n connected = true;\n for (const listener of connectListeners) {\n listener();\n }\n },\n emit(event) {\n for (const listener of listeners) {\n listener(event);\n }\n },\n };\n}\n"],"names":["Context","ReactiveChannelTag","Tag","NoopReactiveChannel","subscribe","undefined","onConnect","makeInMemoryReactiveChannel","listeners","Set","connectListeners","connected","listener","add","delete","connect","emit","event"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AA+CjC,OAAO,MAAMC,2BAA2BD,QAAQE,GAAG,CAAC;AAG/C;AAEL;;;;;;CAMC,GACD,OAAO,MAAMC,sBAAuC;IAClDC,WAAW,IAAM,IAAMC;IACvBC,WAAW,IAAM,IAAMD;AACzB,EAAE;AAeF;;;;;;;CAOC,GACD,OAAO,SAASE;IACd,MAAMC,YAAY,IAAIC;IACtB,MAAMC,mBAAmB,IAAID;IAC7B,IAAIE,YAAY;IAChB,OAAO;QACLP,WAAUQ,QAAQ;YAChBJ,UAAUK,GAAG,CAACD;YACd,OAAO;gBACLJ,UAAUM,MAAM,CAACF;YACnB;QACF;QACAN,WAAUM,QAAQ;YAChBF,iBAAiBG,GAAG,CAACD;YACrB,0EAA0E;YAC1E,+DAA+D;YAC/D,IAAID,WAAWC;YACf,OAAO;gBACLF,iBAAiBI,MAAM,CAACF;YAC1B;QACF;QACAG;YACEJ,YAAY;YACZ,KAAK,MAAMC,YAAYF,iBAAkB;gBACvCE;YACF;QACF;QACAI,MAAKC,KAAK;YACR,KAAK,MAAML,YAAYJ,UAAW;gBAChCI,SAASK;YACX;QACF;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/reactive/reactive-channel.ts"],"sourcesContent":["import type { DomainEvent, EntityChangeEvent } from '@entifix/core';\nimport { Context } from 'effect';\n\nexport type { EntityChangeEvent };\n\n/**\n * A listener on the reactive stream.\n *\n * It receives the whole message, not just the payload: the transaction id, the\n * emission time and the sequence a consumer needs are `meta.event.correlationId`,\n * `.at` and `.id`, and duplicating them onto {@link EntityChangeEvent} is how\n * two sources of one fact drift (ADR 0036). `#137` settles an optimistic write\n * on the correlation id; `useReactiveInvalidation` reads only `data.entity`.\n */\nexport type EntityChangeListener = (\n event: DomainEvent<EntityChangeEvent>,\n) => void;\n\n/**\n * The reactive-updates port. Framework-free on purpose (like the OTel tooling):\n * the transport is swappable, and the React side wires `subscribe` to the query\n * client. `subscribe` returns its own unsubscribe.\n */\nexport interface ReactiveChannel {\n subscribe(listener: EntityChangeListener): () => void;\n /**\n * Runs whenever the stream is (re)connected — and **immediately if it is\n * already connected when the listener registers**.\n *\n * The stream does not replay, by design (ADR 0036), so a consumer that missed\n * an outcome while disconnected has to go and ask. This is the signal that\n * tells it when to: `#137` re-queries every pending transaction id here.\n *\n * ⚠️ **The replay is the mechanism, not a convenience.** The channel is\n * refcounted and opened by its *first* subscriber, and\n * `useReactiveInvalidation` typically is that subscriber. A consumer mounting\n * after it — which depends on nothing more than component-tree ordering —\n * would otherwise register for an open that had already happened, and\n * reconcile nothing until the next real network drop. On a page load with no\n * disconnect, a pending set restored from the previous session would never be\n * reconciled at all, which is the case this exists for.\n *\n * Required rather than optional: an optional member on a port is how one\n * implementation quietly stops honouring it while every consumer compiles.\n */\n onConnect(listener: () => void): () => void;\n}\n\nexport class ReactiveChannelTag extends Context.Service<\n ReactiveChannelTag,\n ReactiveChannel\n>()('ReactiveChannel') {}\n\n/**\n * A channel that never emits — the default until a transport is provided.\n *\n * It never connects either, so `onConnect` registers nothing: a consumer of this\n * channel reconciles never, which is the honest behaviour for a transport that\n * is not there.\n */\nexport const NoopReactiveChannel: ReactiveChannel = {\n subscribe: () => () => undefined,\n onConnect: () => () => undefined,\n};\n\nexport interface InMemoryReactiveChannel extends ReactiveChannel {\n /** Push an event to every current subscriber (drives tests and the mock). */\n emit(event: DomainEvent<EntityChangeEvent>): void;\n /**\n * Announce a (re)connection, so a spec can drive the reconciliation path.\n *\n * Also flips this channel to \"connected\", which is what makes a listener\n * registered afterwards fire immediately — the behaviour the real transport\n * has and the one most likely to be got wrong.\n */\n connect(): void;\n}\n\n/**\n * A synchronous in-memory channel: `emit` fans out to every live subscriber.\n *\n * It backs specs. It is no longer what the workspace runs on — that is\n * `makeEventSourceReactiveChannel`, and until ADR 0036 landed a transport this\n * one was mounted in its place, emitting nothing, so a reader of the workspace\n * could not tell \"the transport is missing\" from \"nothing changed\".\n */\nexport function makeInMemoryReactiveChannel(): InMemoryReactiveChannel {\n const listeners = new Set<EntityChangeListener>();\n const connectListeners = new Set<() => void>();\n let connected = false;\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n onConnect(listener) {\n connectListeners.add(listener);\n // Mirrors the real transport: a listener arriving after the connection is\n // already up must not wait for the next drop to hear about it.\n if (connected) listener();\n return () => {\n connectListeners.delete(listener);\n };\n },\n connect() {\n connected = true;\n for (const listener of connectListeners) {\n listener();\n }\n },\n emit(event) {\n for (const listener of listeners) {\n listener(event);\n }\n },\n };\n}\n"],"names":["Context","ReactiveChannelTag","Service","NoopReactiveChannel","subscribe","undefined","onConnect","makeInMemoryReactiveChannel","listeners","Set","connectListeners","connected","listener","add","delete","connect","emit","event"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AA+CjC,OAAO,MAAMC,2BAA2BD,QAAQE,OAAO,GAGnD;AAAoB;AAExB;;;;;;CAMC,GACD,OAAO,MAAMC,sBAAuC;IAClDC,WAAW,IAAM,IAAMC;IACvBC,WAAW,IAAM,IAAMD;AACzB,EAAE;AAeF;;;;;;;CAOC,GACD,OAAO,SAASE;IACd,MAAMC,YAAY,IAAIC;IACtB,MAAMC,mBAAmB,IAAID;IAC7B,IAAIE,YAAY;IAChB,OAAO;QACLP,WAAUQ,QAAQ;YAChBJ,UAAUK,GAAG,CAACD;YACd,OAAO;gBACLJ,UAAUM,MAAM,CAACF;YACnB;QACF;QACAN,WAAUM,QAAQ;YAChBF,iBAAiBG,GAAG,CAACD;YACrB,0EAA0E;YAC1E,+DAA+D;YAC/D,IAAID,WAAWC;YACf,OAAO;gBACLF,iBAAiBI,MAAM,CAACF;YAC1B;QACF;QACAG;YACEJ,YAAY;YACZ,KAAK,MAAMC,YAAYF,iBAAkB;gBACvCE;YACF;QACF;QACAI,MAAKC,KAAK;YACR,KAAK,MAAML,YAAYJ,UAAW;gBAChCI,SAASK;YACX;QACF;IACF;AACF"}
package/package.json CHANGED
@@ -21,7 +21,7 @@
21
21
  }
22
22
  }
23
23
  },
24
- "version": "0.1.4",
24
+ "version": "0.3.0",
25
25
  "description": "React hooks that bind entities to data: TanStack Query and Form over the entity adapters, drafts, mutations and live invalidation.",
26
26
  "license": "MIT",
27
27
  "repository": {
@@ -39,13 +39,11 @@
39
39
  "exports": {
40
40
  "./package.json": "./package.json",
41
41
  ".": {
42
- "@entifix/source": "./src/index.ts",
43
42
  "types": "./dist/index.d.ts",
44
43
  "import": "./dist/index.js",
45
44
  "default": "./dist/index.js"
46
45
  },
47
46
  "./url": {
48
- "@entifix/source": "./src/url.ts",
49
47
  "types": "./dist/url.d.ts",
50
48
  "import": "./dist/url.js",
51
49
  "default": "./dist/url.js"
@@ -57,21 +55,21 @@
57
55
  "LICENSE"
58
56
  ],
59
57
  "dependencies": {
60
- "@entifix/transactions": "0.1.4",
61
- "@entifix/business": "0.1.4",
62
- "@entifix/core": "0.1.4"
58
+ "@entifix/transactions": "0.3.0",
59
+ "@entifix/core": "0.3.0",
60
+ "@entifix/business": "0.3.0"
63
61
  },
64
62
  "devDependencies": {
65
63
  "@tanstack/react-form": "1.33.5",
66
64
  "@tanstack/react-query": "^5.101.4",
67
- "effect": "3.22.2",
65
+ "effect": "4.0.5",
68
66
  "react": "19.2.8",
69
- "@entifix/testing-unit": "0.1.4"
67
+ "@entifix/testing-unit": "0.3.0"
70
68
  },
71
69
  "peerDependencies": {
72
70
  "@tanstack/react-form": ">=1.33 <2",
73
71
  "@tanstack/react-query": ">=5.101 <6",
74
- "effect": ">=3.22.2 <4",
72
+ "effect": "^4.0.5",
75
73
  "react": ">=19 <20"
76
74
  }
77
75
  }