@entifix/react-integration 0.5.0 → 0.6.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.
@@ -18,8 +18,7 @@ import type { EntityDraftMessages } from './use-entity-form.helpers.js';
18
18
  * hook — so the port stays beside the options it joins.
19
19
  *
20
20
  * Everything it carries is JSON round-trippable, because a draft is written
21
- * through `createJSONStorage`
22
- * ([ADR 0032](../../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).
21
+ * through `createJSONStorage`.
23
22
  * `EntityDraft` is that guarantee's compile-time half.
24
23
  */
25
24
  export interface EntityDraftStore {
@@ -85,7 +84,7 @@ export interface UseEntityFormOptions<TEntity extends Entity> {
85
84
  *
86
85
  * The submit therefore receives a **partial** draft. Merging the steps and
87
86
  * rebuilding the entity is the host's job, exactly as it already is for one
88
- * form ([ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)).
87
+ * form.
89
88
  *
90
89
  * A fresh array literal per render is fine: the memo behind it keys on the
91
90
  * names rather than the array's identity.
@@ -1 +1 @@
1
- {"version":3,"file":"use-entity-form.types.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-entity-form/use-entity-form.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,MAAM,EACN,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B;;;;;OAKG;IACH,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB,CAAC,OAAO,SAAS,MAAM;IAC1D,yEAAyE;IACzE,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAC9C;;;OAGG;IACH,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IACzC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;IACzE,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3D,uDAAuD;IACvD,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,mBAAmB;IAClC,qEAAqE;IACrE,MAAM,EAAE,WAAW,CAAC;IACpB,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC1D;;;;;;;;;OASG;IACH,KAAK,EAAE,mBAAmB,CAAC;IAC3B;;;OAGG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAC5D;;;;;;;;OAQG;IACH,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD;;;;;;;;OAQG;IACH,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/B,8DAA8D;IAC9D,OAAO,EAAE,OAAO,CAAC;CAClB"}
1
+ {"version":3,"file":"use-entity-form.types.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-entity-form/use-entity-form.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,MAAM,EACN,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B;;;;;OAKG;IACH,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB,CAAC,OAAO,SAAS,MAAM;IAC1D,yEAAyE;IACzE,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAC9C;;;OAGG;IACH,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IACzC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;IACzE,wEAAwE;IACxE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3D,uDAAuD;IACvD,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,mBAAmB;IAClC,qEAAqE;IACrE,MAAM,EAAE,WAAW,CAAC;IACpB,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC1D;;;;;;;;;OASG;IACH,KAAK,EAAE,mBAAmB,CAAC;IAC3B;;;OAGG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAC5D;;;;;;;;OAQG;IACH,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD;;;;;;;;OAQG;IACH,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/B,8DAA8D;IAC9D,OAAO,EAAE,OAAO,CAAC;CAClB"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/use-entity-form/use-entity-form.types.ts"],"sourcesContent":["import type {\n Entity,\n EntityConstructor,\n EntityDraft,\n EntityDraftValue,\n EntityLinkSelection,\n StandardSchemaV1,\n} from '@entifix/core';\n\nimport type { EntityDraftMessages } from './use-entity-form.helpers.js';\n\n/**\n * Where an autosaved draft is persisted, as the form sees it.\n *\n * A **port**, because the store cannot be imported: `useDraft` and the zustand\n * store behind it are in `@entifix/next-shell` (T4), this package is T3, and\n * `@nx/enforce-module-boundaries` fails the build on the upward edge. Moving\n * the store down would put IndexedDB into the agnostic UI tier, so\n * the shell implements this instead — `useEntityDraft` in `@entifix/next-shell`\n * is the one adapter today.\n *\n * It lives here rather than in `@entifix/core`, and the asymmetry with\n * `EntityLinkSource` is deliberate: that port sits in core because\n * `@entifix/react-controls` and `@entifix/react-integration` are both\n * T3 with no declared edge between them, so they *had* to meet below\n * both. Nothing forces that here — `@entifix/next-shell` already imports this\n * hook — so the port stays beside the options it joins.\n *\n * Everything it carries is JSON round-trippable, because a draft is written\n * through `createJSONStorage`\n * ([ADR 0032](../../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).\n * `EntityDraft` is that guarantee's compile-time half.\n */\nexport interface EntityDraftStore {\n /**\n * The persisted draft, layered over the record's seed — how a workspace\n * restores an autosaved, half-finished edit.\n *\n * It wins on *values*, never on *keys*: a member the entity no longer declares\n * is dropped and one the draft never held keeps its seeded value, so a draft\n * written under an older shape cannot leave a field uncontrolled. See\n * `restoreEntityDraft`.\n */\n readonly draft?: EntityDraft;\n /**\n * Persist the draft. Called on every edit away from the seed.\n *\n * Must be referentially stable across renders: the hook writes from an effect\n * keyed on it, so a new identity each render turns every render into a write.\n */\n save(draft: EntityDraft): void;\n /**\n * Discard the persisted draft.\n *\n * **The hook never calls this.** A draft is spent when the write *commits*,\n * and the hook does not know whether it did — it neither fetches nor saves.\n * The host that owns the mutation calls it on success. Naming it on the port\n * anyway is what stops each host re-deriving the address it would clear by.\n */\n clear(): void;\n}\n\nexport interface UseEntityFormOptions<TEntity extends Entity> {\n /** Metadata source; fields and their validation rules derive from it. */\n entityConstructor: EntityConstructor<TEntity>;\n /**\n * Copy for the four field-level validation failures. Omit and the keys\n * render, which is what an adopter with no i18n library sees.\n */\n validationMessages?: EntityDraftMessages;\n /**\n * Resolves a schema issue's message, which is authored as a catalog key\n * (`validation.minLength`) so a rule written in one language never reaches a\n * user untranslated. `useTranslateKey` from the controls package is what a\n * host passes; omit it and the message renders as authored.\n */\n translateKey?: (key: string, params?: Record<string, unknown>) => string;\n /** The record being edited; seeds the initial draft. Omit to create. */\n entity?: TEntity;\n /**\n * Where to persist this form's draft, so a half-finished edit survives a\n * refresh. Omit and the form is ephemeral — which is what a plain route wants,\n * and why autosave is opt-in rather than a flag a host can set wrong.\n */\n draft?: EntityDraftStore;\n /**\n * Edit only these members, by accessor name. Omit and the form owns them all.\n *\n * It narrows the **descriptors**, so it narrows seeding, coercion *and*\n * validation together — which is the whole point. A wizard splits one entity\n * across several steps, and without this every step would validate the whole\n * record: step one could not advance until members it does not show were\n * filled, and the required-ness of a field would depend on which step you\n * were standing on.\n *\n * The submit therefore receives a **partial** draft. Merging the steps and\n * rebuilding the entity is the host's job, exactly as it already is for one\n * form ([ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)).\n *\n * A fresh array literal per render is fine: the memo behind it keys on the\n * names rather than the array's identity.\n */\n fields?: readonly string[];\n /**\n * A Standard Schema (Zod, Valibot, ArkType — anything exposing `~standard`)\n * for the rules metadata cannot express: regex, min/max, cross-field.\n *\n * Authored against the **string draft**, so it coerces (`z.coerce.number()`)\n * rather than expecting typed values. Each message is a catalog key\n * (`validation.minLength`), resolved against the `controls` namespace with the\n * literal as its fallback — a rule written in one language would otherwise\n * reach the user untranslated. An issue with no path becomes `formError`.\n *\n * Synchronous rules only: validation runs in a synchronous pass, so an async\n * schema throws rather than silently passing. Put asynchronous checks — a\n * uniqueness lookup, say — behind {@link UseEntityFormOptions.validate}.\n */\n schema?: StandardSchemaV1;\n /**\n * Extra validation beyond the metadata and schema rules, returning a\n * field-name → message map. Applied last, so it wins on conflict.\n */\n validate?: (values: EntityDraft) => Record<string, string>;\n /** Called with the draft once it passes validation. */\n onSubmit: (values: EntityDraft) => void | Promise<void>;\n}\n\nexport interface UseEntityFormResult {\n /** The current draft. Feed straight into `EntityForm`'s `values`. */\n values: EntityDraft;\n /** Field-name → message, surfaced only after a submit attempt. */\n errors: Record<string, string>;\n /**\n * The message that belongs to no single field — a cross-field schema rule.\n * Feed into `EntityForm`'s `formError`, which has nowhere else to show it.\n */\n formError?: string;\n /**\n * Feed into `EntityForm`'s `onFieldChange`.\n *\n * A scalar takes a string; a `composition` takes its list of rows, the one\n * member with no lossless string form.\n */\n setField: (name: string, value: EntityDraftValue) => void;\n /**\n * The instances behind the relation ids the draft holds — seeded from the\n * record's already-materialized links, extended by every pick, and refilled\n * from a resolved id by {@link UseEntityFormResult.hydrateLink}.\n *\n * Pass to `applyEntityLinks` at submit: the draft alone cannot express an\n * embedded relation, because an id is not an entity. It is not persisted — a\n * restored draft arrives with ids and no instances, which is what\n * `hydrateLink` exists to repair.\n */\n links: EntityLinkSelection;\n /**\n * Feed into `EntityForm`'s `onLinkChange`. Records the picked instance and\n * writes its id into the draft, which stays the serializable source of truth.\n */\n setLink: (name: string, entity: Entity | undefined) => void;\n /**\n * Record an instance resolved from an id the draft already held — the restore\n * path, not a pick.\n *\n * Unlike {@link UseEntityFormResult.setLink} it writes only the sidecar, so it\n * cannot dirty a form the user has not touched. It also never overwrites an\n * entry that is already there: a pick in flight is newer than a lookup that\n * started before it.\n */\n hydrateLink: (name: string, resolved: Entity) => void;\n /**\n * Validate, then submit when clean. Feed into `EntityForm`'s `onSubmit`.\n *\n * **Resolves `true` only when the submit actually ran**, so a caller that has\n * to gate on the outcome — a wizard, whose \"Siguiente\" *is* the step's submit\n * — can await it instead of awaiting a tick and re-reading `errors`. A caller\n * with nothing to gate ignores the promise and is unaffected, which is every\n * existing one.\n */\n submit: () => Promise<boolean>;\n /** True once any field has been edited away from its seed. */\n isDirty: boolean;\n}\n"],"names":[],"mappings":"AA+HA,WAuDC"}
1
+ {"version":3,"sources":["../../../src/hooks/use-entity-form/use-entity-form.types.ts"],"sourcesContent":["import type {\n Entity,\n EntityConstructor,\n EntityDraft,\n EntityDraftValue,\n EntityLinkSelection,\n StandardSchemaV1,\n} from '@entifix/core';\n\nimport type { EntityDraftMessages } from './use-entity-form.helpers.js';\n\n/**\n * Where an autosaved draft is persisted, as the form sees it.\n *\n * A **port**, because the store cannot be imported: `useDraft` and the zustand\n * store behind it are in `@entifix/next-shell` (T4), this package is T3, and\n * `@nx/enforce-module-boundaries` fails the build on the upward edge. Moving\n * the store down would put IndexedDB into the agnostic UI tier, so\n * the shell implements this instead — `useEntityDraft` in `@entifix/next-shell`\n * is the one adapter today.\n *\n * It lives here rather than in `@entifix/core`, and the asymmetry with\n * `EntityLinkSource` is deliberate: that port sits in core because\n * `@entifix/react-controls` and `@entifix/react-integration` are both\n * T3 with no declared edge between them, so they *had* to meet below\n * both. Nothing forces that here — `@entifix/next-shell` already imports this\n * hook — so the port stays beside the options it joins.\n *\n * Everything it carries is JSON round-trippable, because a draft is written\n * through `createJSONStorage`.\n * `EntityDraft` is that guarantee's compile-time half.\n */\nexport interface EntityDraftStore {\n /**\n * The persisted draft, layered over the record's seed — how a workspace\n * restores an autosaved, half-finished edit.\n *\n * It wins on *values*, never on *keys*: a member the entity no longer declares\n * is dropped and one the draft never held keeps its seeded value, so a draft\n * written under an older shape cannot leave a field uncontrolled. See\n * `restoreEntityDraft`.\n */\n readonly draft?: EntityDraft;\n /**\n * Persist the draft. Called on every edit away from the seed.\n *\n * Must be referentially stable across renders: the hook writes from an effect\n * keyed on it, so a new identity each render turns every render into a write.\n */\n save(draft: EntityDraft): void;\n /**\n * Discard the persisted draft.\n *\n * **The hook never calls this.** A draft is spent when the write *commits*,\n * and the hook does not know whether it did — it neither fetches nor saves.\n * The host that owns the mutation calls it on success. Naming it on the port\n * anyway is what stops each host re-deriving the address it would clear by.\n */\n clear(): void;\n}\n\nexport interface UseEntityFormOptions<TEntity extends Entity> {\n /** Metadata source; fields and their validation rules derive from it. */\n entityConstructor: EntityConstructor<TEntity>;\n /**\n * Copy for the four field-level validation failures. Omit and the keys\n * render, which is what an adopter with no i18n library sees.\n */\n validationMessages?: EntityDraftMessages;\n /**\n * Resolves a schema issue's message, which is authored as a catalog key\n * (`validation.minLength`) so a rule written in one language never reaches a\n * user untranslated. `useTranslateKey` from the controls package is what a\n * host passes; omit it and the message renders as authored.\n */\n translateKey?: (key: string, params?: Record<string, unknown>) => string;\n /** The record being edited; seeds the initial draft. Omit to create. */\n entity?: TEntity;\n /**\n * Where to persist this form's draft, so a half-finished edit survives a\n * refresh. Omit and the form is ephemeral — which is what a plain route wants,\n * and why autosave is opt-in rather than a flag a host can set wrong.\n */\n draft?: EntityDraftStore;\n /**\n * Edit only these members, by accessor name. Omit and the form owns them all.\n *\n * It narrows the **descriptors**, so it narrows seeding, coercion *and*\n * validation together — which is the whole point. A wizard splits one entity\n * across several steps, and without this every step would validate the whole\n * record: step one could not advance until members it does not show were\n * filled, and the required-ness of a field would depend on which step you\n * were standing on.\n *\n * The submit therefore receives a **partial** draft. Merging the steps and\n * rebuilding the entity is the host's job, exactly as it already is for one\n * form.\n *\n * A fresh array literal per render is fine: the memo behind it keys on the\n * names rather than the array's identity.\n */\n fields?: readonly string[];\n /**\n * A Standard Schema (Zod, Valibot, ArkType — anything exposing `~standard`)\n * for the rules metadata cannot express: regex, min/max, cross-field.\n *\n * Authored against the **string draft**, so it coerces (`z.coerce.number()`)\n * rather than expecting typed values. Each message is a catalog key\n * (`validation.minLength`), resolved against the `controls` namespace with the\n * literal as its fallback — a rule written in one language would otherwise\n * reach the user untranslated. An issue with no path becomes `formError`.\n *\n * Synchronous rules only: validation runs in a synchronous pass, so an async\n * schema throws rather than silently passing. Put asynchronous checks — a\n * uniqueness lookup, say — behind {@link UseEntityFormOptions.validate}.\n */\n schema?: StandardSchemaV1;\n /**\n * Extra validation beyond the metadata and schema rules, returning a\n * field-name → message map. Applied last, so it wins on conflict.\n */\n validate?: (values: EntityDraft) => Record<string, string>;\n /** Called with the draft once it passes validation. */\n onSubmit: (values: EntityDraft) => void | Promise<void>;\n}\n\nexport interface UseEntityFormResult {\n /** The current draft. Feed straight into `EntityForm`'s `values`. */\n values: EntityDraft;\n /** Field-name → message, surfaced only after a submit attempt. */\n errors: Record<string, string>;\n /**\n * The message that belongs to no single field — a cross-field schema rule.\n * Feed into `EntityForm`'s `formError`, which has nowhere else to show it.\n */\n formError?: string;\n /**\n * Feed into `EntityForm`'s `onFieldChange`.\n *\n * A scalar takes a string; a `composition` takes its list of rows, the one\n * member with no lossless string form.\n */\n setField: (name: string, value: EntityDraftValue) => void;\n /**\n * The instances behind the relation ids the draft holds — seeded from the\n * record's already-materialized links, extended by every pick, and refilled\n * from a resolved id by {@link UseEntityFormResult.hydrateLink}.\n *\n * Pass to `applyEntityLinks` at submit: the draft alone cannot express an\n * embedded relation, because an id is not an entity. It is not persisted — a\n * restored draft arrives with ids and no instances, which is what\n * `hydrateLink` exists to repair.\n */\n links: EntityLinkSelection;\n /**\n * Feed into `EntityForm`'s `onLinkChange`. Records the picked instance and\n * writes its id into the draft, which stays the serializable source of truth.\n */\n setLink: (name: string, entity: Entity | undefined) => void;\n /**\n * Record an instance resolved from an id the draft already held — the restore\n * path, not a pick.\n *\n * Unlike {@link UseEntityFormResult.setLink} it writes only the sidecar, so it\n * cannot dirty a form the user has not touched. It also never overwrites an\n * entry that is already there: a pick in flight is newer than a lookup that\n * started before it.\n */\n hydrateLink: (name: string, resolved: Entity) => void;\n /**\n * Validate, then submit when clean. Feed into `EntityForm`'s `onSubmit`.\n *\n * **Resolves `true` only when the submit actually ran**, so a caller that has\n * to gate on the outcome — a wizard, whose \"Siguiente\" *is* the step's submit\n * — can await it instead of awaiting a tick and re-reading `errors`. A caller\n * with nothing to gate ignores the promise and is unaffected, which is every\n * existing one.\n */\n submit: () => Promise<boolean>;\n /** True once any field has been edited away from its seed. */\n isDirty: boolean;\n}\n"],"names":[],"mappings":"AA8HA,WAuDC"}
@@ -8,8 +8,7 @@ import type { UseWizardOptions, UseWizardResult } from './use-wizard.types.js';
8
8
  * calls in one component — and it unmounts when it is not the active step. What
9
9
  * makes Back safe is therefore not keeping the step mounted but keeping its
10
10
  * values out of it: {@link UseWizardResult.draftStoreFor} hands the step an
11
- * `EntityDraftStore` viewing this state, and the step is free to come and go
12
- * ([ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)).
11
+ * `EntityDraftStore` viewing this state, and the step is free to come and go.
13
12
  *
14
13
  * It holds no router and performs no submit. `onStepChange` reports where the
15
14
  * operator went so a shell can write the URL, and finishing is the host's — this
@@ -1 +1 @@
1
- {"version":3,"file":"use-wizard.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-wizard/use-wizard.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE/E;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,EACxB,UAAU,EACV,KAAK,EACL,OAAO,EACP,YAAY,GACb,EAAE,gBAAgB,GAAG,eAAe,CAoIpC"}
1
+ {"version":3,"file":"use-wizard.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-wizard/use-wizard.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE/E;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,EACxB,UAAU,EACV,KAAK,EACL,OAAO,EACP,YAAY,GACb,EAAE,gBAAgB,GAAG,eAAe,CAqIpC"}
@@ -11,8 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
11
  * calls in one component — and it unmounts when it is not the active step. What
12
12
  * makes Back safe is therefore not keeping the step mounted but keeping its
13
13
  * values out of it: {@link UseWizardResult.draftStoreFor} hands the step an
14
- * `EntityDraftStore` viewing this state, and the step is free to come and go
15
- * ([ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)).
14
+ * `EntityDraftStore` viewing this state, and the step is free to come and go.
16
15
  *
17
16
  * It holds no router and performs no submit. `onStepChange` reports where the
18
17
  * operator went so a shell can write the URL, and finishing is the host's — this
@@ -57,7 +56,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
57
56
  /**
58
57
  * A persisted wizard is adopted **once, and only while untouched**.
59
58
  *
60
- * The store hydrates from an effect (`skipHydration`, ADR 0032), so it arrives
59
+ * The store hydrates from an effect (`skipHydration`), so it arrives
61
60
  * after the first render and cannot be a `useState` initializer. Adopting it
62
61
  * more than once would let a later hydration overwrite what the operator has
63
62
  * since typed, and adopting it after a first edit would do the same on the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/use-wizard/use-wizard.ts"],"sourcesContent":["'use client';\n\nimport {\n advanceWizard,\n canFinish as canFinishWizard,\n emptyWizardState,\n type EntityDraft,\n entryStep,\n goBackWizard,\n goToWizardStep,\n stepStatuses,\n withStepValue,\n type WizardState,\n type WizardStepValue,\n} from '@entifix/core';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport type { EntityDraftStore } from '../use-entity-form/use-entity-form.types.js';\nimport type { UseWizardOptions, UseWizardResult } from './use-wizard.types.js';\n\n/**\n * Drives one wizard: where the operator is, what they have answered, and the\n * per-step draft each form step edits.\n *\n * **The draft lives here, above the forms.** A form step calls `useEntityForm`\n * itself — React's hook count must stay fixed, so N form steps cannot be N hook\n * calls in one component — and it unmounts when it is not the active step. What\n * makes Back safe is therefore not keeping the step mounted but keeping its\n * values out of it: {@link UseWizardResult.draftStoreFor} hands the step an\n * `EntityDraftStore` viewing this state, and the step is free to come and go\n * ([ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)).\n *\n * It holds no router and performs no submit. `onStepChange` reports where the\n * operator went so a shell can write the URL, and finishing is the host's — this\n * hook does not know a transaction exists.\n */\nexport function useWizard({\n definition,\n draft,\n errored,\n onStepChange,\n}: UseWizardOptions): UseWizardResult {\n const entry = useMemo(() => entryStep(definition), [definition]);\n const [state, setState] = useState<WizardState>(() =>\n emptyWizardState(entry.id),\n );\n const [isResumed, setResumed] = useState(false);\n\n /**\n * The state as of the last commit, mirrored so a handler can read it without\n * a stale closure **and without doing its work inside a `setState` updater** —\n * an updater runs twice under StrictMode, which would fire `onStepChange`\n * and the persistence write twice per advance.\n */\n const stateRef = useRef(state);\n const touched = useRef(false);\n const adopted = useRef(false);\n\n const save = draft?.save;\n const persisted = draft?.state;\n\n const commit = useCallback(\n (updated: WizardState) => {\n const current = stateRef.current;\n if (updated === current) return;\n\n touched.current = true;\n stateRef.current = updated;\n setState(updated);\n save?.(updated);\n\n if (updated.activeStep !== current.activeStep) {\n // Resuming is a fact about arriving. Once the operator moves, the recap\n // has been read or ignored, and a banner that never leaves stops being\n // read at all.\n setResumed(false);\n onStepChange?.(updated.activeStep);\n }\n },\n [save, onStepChange],\n );\n\n const apply = useCallback(\n (next: (current: WizardState) => WizardState) =>\n commit(next(stateRef.current)),\n [commit],\n );\n\n /**\n * A persisted wizard is adopted **once, and only while untouched**.\n *\n * The store hydrates from an effect (`skipHydration`, ADR 0032), so it arrives\n * after the first render and cannot be a `useState` initializer. Adopting it\n * more than once would let a later hydration overwrite what the operator has\n * since typed, and adopting it after a first edit would do the same on the\n * very first keystroke.\n */\n useEffect(() => {\n if (adopted.current || touched.current || persisted === undefined) return;\n adopted.current = true;\n stateRef.current = persisted;\n setState(persisted);\n // Only a wizard that was genuinely mid-flow is a resume; one persisted on\n // its first step has nothing to recap.\n setResumed(persisted.history.length > 0);\n }, [persisted]);\n\n /**\n * One draft store per step id, minted once and kept forever.\n *\n * `useEntityForm` writes from an effect keyed on `save`, so a new identity per\n * render would make every render a write. A `Map` in a ref rather than a memo\n * because the set of steps a caller asks for grows as the flow is walked, and\n * a memo keyed on that set would rebuild every entry whenever one was added.\n */\n const stores = useRef(\n new Map<string, Pick<EntityDraftStore, 'save' | 'clear'>>(),\n );\n\n const draftStoreFor = useCallback(\n (stepId: string): EntityDraftStore => {\n let store = stores.current.get(stepId);\n if (store === undefined) {\n store = {\n save: (values: EntityDraft) =>\n apply(current =>\n withStepValue(current, stepId, { kind: 'form', values }),\n ),\n clear: () =>\n apply(current => withStepValue(current, stepId, { kind: 'none' })),\n };\n stores.current.set(stepId, store);\n }\n\n const value = state.steps[stepId];\n return {\n // The stored reference, and the key is **omitted** when the step holds\n // no draft — a fresh `{}` per render would re-seed the form every render,\n // which is the `Maximum update depth exceeded` hang ADR 0038 measured.\n ...(value?.kind === 'form' ? { draft: value.values } : {}),\n save: store.save,\n clear: store.clear,\n };\n },\n [apply, state],\n );\n\n return {\n state,\n activeStep: state.activeStep,\n steps: stepStatuses(definition, state, errored),\n canFinish: canFinishWizard(definition, state),\n isResumed,\n draftStoreFor,\n setStepValue: useCallback(\n (stepId: string, value: WizardStepValue) =>\n apply(current => withStepValue(current, stepId, value)),\n [apply],\n ),\n next: useCallback(\n () => apply(current => advanceWizard(definition, current)),\n [apply, definition],\n ),\n previous: useCallback(() => apply(goBackWizard), [apply]),\n goTo: useCallback(\n (stepId: string) => apply(current => goToWizardStep(current, stepId)),\n [apply],\n ),\n reset: useCallback(\n () => apply(() => emptyWizardState(entry.id)),\n [apply, entry],\n ),\n };\n}\n"],"names":["advanceWizard","canFinish","canFinishWizard","emptyWizardState","entryStep","goBackWizard","goToWizardStep","stepStatuses","withStepValue","useCallback","useEffect","useMemo","useRef","useState","useWizard","definition","draft","errored","onStepChange","entry","state","setState","id","isResumed","setResumed","stateRef","touched","adopted","save","persisted","commit","updated","current","activeStep","apply","next","undefined","history","length","stores","Map","draftStoreFor","stepId","store","get","values","kind","clear","set","value","steps","setStepValue","previous","goTo","reset"],"mappings":"AAAA;;AAEA,SACEA,aAAa,EACbC,aAAaC,eAAe,EAC5BC,gBAAgB,EAEhBC,SAAS,EACTC,YAAY,EACZC,cAAc,EACdC,YAAY,EACZC,aAAa,QAGR,gBAAgB;AACvB,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAQ;AAK1E;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,UAAU,EACxBC,UAAU,EACVC,KAAK,EACLC,OAAO,EACPC,YAAY,EACK;IACjB,MAAMC,QAAQR,QAAQ,IAAMP,UAAUW,aAAa;QAACA;KAAW;IAC/D,MAAM,CAACK,OAAOC,SAAS,GAAGR,SAAsB,IAC9CV,iBAAiBgB,MAAMG,EAAE;IAE3B,MAAM,CAACC,WAAWC,WAAW,GAAGX,SAAS;IAEzC;;;;;GAKC,GACD,MAAMY,WAAWb,OAAOQ;IACxB,MAAMM,UAAUd,OAAO;IACvB,MAAMe,UAAUf,OAAO;IAEvB,MAAMgB,OAAOZ,yBAAAA,MAAOY,IAAI;IACxB,MAAMC,YAAYb,yBAAAA,MAAOI,KAAK;IAE9B,MAAMU,SAASrB,YACb,CAACsB;QACC,MAAMC,UAAUP,SAASO,OAAO;QAChC,IAAID,YAAYC,SAAS;QAEzBN,QAAQM,OAAO,GAAG;QAClBP,SAASO,OAAO,GAAGD;QACnBV,SAASU;QACTH,wBAAAA,KAAOG;QAEP,IAAIA,QAAQE,UAAU,KAAKD,QAAQC,UAAU,EAAE;YAC7C,wEAAwE;YACxE,uEAAuE;YACvE,eAAe;YACfT,WAAW;YACXN,gCAAAA,aAAea,QAAQE,UAAU;QACnC;IACF,GACA;QAACL;QAAMV;KAAa;IAGtB,MAAMgB,QAAQzB,YACZ,CAAC0B,OACCL,OAAOK,KAAKV,SAASO,OAAO,IAC9B;QAACF;KAAO;IAGV;;;;;;;;GAQC,GACDpB,UAAU;QACR,IAAIiB,QAAQK,OAAO,IAAIN,QAAQM,OAAO,IAAIH,cAAcO,WAAW;QACnET,QAAQK,OAAO,GAAG;QAClBP,SAASO,OAAO,GAAGH;QACnBR,SAASQ;QACT,0EAA0E;QAC1E,uCAAuC;QACvCL,WAAWK,UAAUQ,OAAO,CAACC,MAAM,GAAG;IACxC,GAAG;QAACT;KAAU;IAEd;;;;;;;GAOC,GACD,MAAMU,SAAS3B,OACb,IAAI4B;IAGN,MAAMC,gBAAgBhC,YACpB,CAACiC;QACC,IAAIC,QAAQJ,OAAOP,OAAO,CAACY,GAAG,CAACF;QAC/B,IAAIC,UAAUP,WAAW;YACvBO,QAAQ;gBACNf,MAAM,CAACiB,SACLX,MAAMF,CAAAA,UACJxB,cAAcwB,SAASU,QAAQ;4BAAEI,MAAM;4BAAQD;wBAAO;gBAE1DE,OAAO,IACLb,MAAMF,CAAAA,UAAWxB,cAAcwB,SAASU,QAAQ;4BAAEI,MAAM;wBAAO;YACnE;YACAP,OAAOP,OAAO,CAACgB,GAAG,CAACN,QAAQC;QAC7B;QAEA,MAAMM,QAAQ7B,MAAM8B,KAAK,CAACR,OAAO;QACjC,OAAO,aAIDO,CAAAA,yBAAAA,MAAOH,IAAI,MAAK,SAAS;YAAE9B,OAAOiC,MAAMJ,MAAM;QAAC,IAAI,CAAC;YACxDjB,MAAMe,MAAMf,IAAI;YAChBmB,OAAOJ,MAAMI,KAAK;;IAEtB,GACA;QAACb;QAAOd;KAAM;IAGhB,OAAO;QACLA;QACAa,YAAYb,MAAMa,UAAU;QAC5BiB,OAAO3C,aAAaQ,YAAYK,OAAOH;QACvChB,WAAWC,gBAAgBa,YAAYK;QACvCG;QACAkB;QACAU,cAAc1C,YACZ,CAACiC,QAAgBO,QACff,MAAMF,CAAAA,UAAWxB,cAAcwB,SAASU,QAAQO,SAClD;YAACf;SAAM;QAETC,MAAM1B,YACJ,IAAMyB,MAAMF,CAAAA,UAAWhC,cAAce,YAAYiB,WACjD;YAACE;YAAOnB;SAAW;QAErBqC,UAAU3C,YAAY,IAAMyB,MAAM7B,eAAe;YAAC6B;SAAM;QACxDmB,MAAM5C,YACJ,CAACiC,SAAmBR,MAAMF,CAAAA,UAAW1B,eAAe0B,SAASU,UAC7D;YAACR;SAAM;QAEToB,OAAO7C,YACL,IAAMyB,MAAM,IAAM/B,iBAAiBgB,MAAMG,EAAE,IAC3C;YAACY;YAAOf;SAAM;IAElB;AACF"}
1
+ {"version":3,"sources":["../../../src/hooks/use-wizard/use-wizard.ts"],"sourcesContent":["'use client';\n\nimport {\n advanceWizard,\n canFinish as canFinishWizard,\n emptyWizardState,\n type EntityDraft,\n entryStep,\n goBackWizard,\n goToWizardStep,\n stepStatuses,\n withStepValue,\n type WizardState,\n type WizardStepValue,\n} from '@entifix/core';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport type { EntityDraftStore } from '../use-entity-form/use-entity-form.types.js';\nimport type { UseWizardOptions, UseWizardResult } from './use-wizard.types.js';\n\n/**\n * Drives one wizard: where the operator is, what they have answered, and the\n * per-step draft each form step edits.\n *\n * **The draft lives here, above the forms.** A form step calls `useEntityForm`\n * itself — React's hook count must stay fixed, so N form steps cannot be N hook\n * calls in one component — and it unmounts when it is not the active step. What\n * makes Back safe is therefore not keeping the step mounted but keeping its\n * values out of it: {@link UseWizardResult.draftStoreFor} hands the step an\n * `EntityDraftStore` viewing this state, and the step is free to come and go.\n *\n * It holds no router and performs no submit. `onStepChange` reports where the\n * operator went so a shell can write the URL, and finishing is the host's — this\n * hook does not know a transaction exists.\n */\nexport function useWizard({\n definition,\n draft,\n errored,\n onStepChange,\n}: UseWizardOptions): UseWizardResult {\n const entry = useMemo(() => entryStep(definition), [definition]);\n const [state, setState] = useState<WizardState>(() =>\n emptyWizardState(entry.id),\n );\n const [isResumed, setResumed] = useState(false);\n\n /**\n * The state as of the last commit, mirrored so a handler can read it without\n * a stale closure **and without doing its work inside a `setState` updater** —\n * an updater runs twice under StrictMode, which would fire `onStepChange`\n * and the persistence write twice per advance.\n */\n const stateRef = useRef(state);\n const touched = useRef(false);\n const adopted = useRef(false);\n\n const save = draft?.save;\n const persisted = draft?.state;\n\n const commit = useCallback(\n (updated: WizardState) => {\n const current = stateRef.current;\n if (updated === current) return;\n\n touched.current = true;\n stateRef.current = updated;\n setState(updated);\n save?.(updated);\n\n if (updated.activeStep !== current.activeStep) {\n // Resuming is a fact about arriving. Once the operator moves, the recap\n // has been read or ignored, and a banner that never leaves stops being\n // read at all.\n setResumed(false);\n onStepChange?.(updated.activeStep);\n }\n },\n [save, onStepChange],\n );\n\n const apply = useCallback(\n (next: (current: WizardState) => WizardState) =>\n commit(next(stateRef.current)),\n [commit],\n );\n\n /**\n * A persisted wizard is adopted **once, and only while untouched**.\n *\n * The store hydrates from an effect (`skipHydration`), so it arrives\n * after the first render and cannot be a `useState` initializer. Adopting it\n * more than once would let a later hydration overwrite what the operator has\n * since typed, and adopting it after a first edit would do the same on the\n * very first keystroke.\n */\n useEffect(() => {\n if (adopted.current || touched.current || persisted === undefined) return;\n adopted.current = true;\n stateRef.current = persisted;\n setState(persisted);\n // Only a wizard that was genuinely mid-flow is a resume; one persisted on\n // its first step has nothing to recap.\n setResumed(persisted.history.length > 0);\n }, [persisted]);\n\n /**\n * One draft store per step id, minted once and kept forever.\n *\n * `useEntityForm` writes from an effect keyed on `save`, so a new identity per\n * render would make every render a write. A `Map` in a ref rather than a memo\n * because the set of steps a caller asks for grows as the flow is walked, and\n * a memo keyed on that set would rebuild every entry whenever one was added.\n */\n const stores = useRef(\n new Map<string, Pick<EntityDraftStore, 'save' | 'clear'>>(),\n );\n\n const draftStoreFor = useCallback(\n (stepId: string): EntityDraftStore => {\n let store = stores.current.get(stepId);\n if (store === undefined) {\n store = {\n save: (values: EntityDraft) =>\n apply(current =>\n withStepValue(current, stepId, { kind: 'form', values }),\n ),\n clear: () =>\n apply(current => withStepValue(current, stepId, { kind: 'none' })),\n };\n stores.current.set(stepId, store);\n }\n\n const value = state.steps[stepId];\n return {\n // The stored reference, and the key is **omitted** when the step holds\n // no draft — a fresh `{}` per render would re-seed the form every render,\n // which is the `Maximum update depth exceeded` hang measured on the\n // master-detail form.\n ...(value?.kind === 'form' ? { draft: value.values } : {}),\n save: store.save,\n clear: store.clear,\n };\n },\n [apply, state],\n );\n\n return {\n state,\n activeStep: state.activeStep,\n steps: stepStatuses(definition, state, errored),\n canFinish: canFinishWizard(definition, state),\n isResumed,\n draftStoreFor,\n setStepValue: useCallback(\n (stepId: string, value: WizardStepValue) =>\n apply(current => withStepValue(current, stepId, value)),\n [apply],\n ),\n next: useCallback(\n () => apply(current => advanceWizard(definition, current)),\n [apply, definition],\n ),\n previous: useCallback(() => apply(goBackWizard), [apply]),\n goTo: useCallback(\n (stepId: string) => apply(current => goToWizardStep(current, stepId)),\n [apply],\n ),\n reset: useCallback(\n () => apply(() => emptyWizardState(entry.id)),\n [apply, entry],\n ),\n };\n}\n"],"names":["advanceWizard","canFinish","canFinishWizard","emptyWizardState","entryStep","goBackWizard","goToWizardStep","stepStatuses","withStepValue","useCallback","useEffect","useMemo","useRef","useState","useWizard","definition","draft","errored","onStepChange","entry","state","setState","id","isResumed","setResumed","stateRef","touched","adopted","save","persisted","commit","updated","current","activeStep","apply","next","undefined","history","length","stores","Map","draftStoreFor","stepId","store","get","values","kind","clear","set","value","steps","setStepValue","previous","goTo","reset"],"mappings":"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"}
@@ -73,7 +73,8 @@ export interface UseWizardResult {
73
73
  * reference, so a step that has not changed re-seeds nothing. Building either
74
74
  * fresh per render is the write-amplification `useEntityDraft` documents, and
75
75
  * a fresh `{}` for an unanswered step is worse — it re-seeds the form every
76
- * render, which is the `Maximum update depth exceeded` hang ADR 0038 measured.
76
+ * render, which is the `Maximum update depth exceeded` hang measured on the
77
+ * master-detail form.
77
78
  */
78
79
  draftStoreFor(stepId: string): EntityDraftStore;
79
80
  /** Record a non-form step's answer — a choice, a table selection. */
@@ -1 +1 @@
1
- {"version":3,"file":"use-wizard.types.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-wizard/use-wizard.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,eAAe,EAChB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6CAA6C,CAAC;AAEpF;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B;;;;;;OAMG;IACH,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,oEAAoE;IACpE,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,WAAW,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,KAAK,EAAE,aAAa,CAAC;QACnB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;KACnC,CAAC,CAAC;IACH,qCAAqC;IACrC,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB;;;;;;;;;OASG;IACH,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAAC;IAChD,qEAAqE;IACrE,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IAE3D,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,IAAI,IAAI,CAAC;IACjB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,KAAK,IAAI,IAAI,CAAC;CACf"}
1
+ {"version":3,"file":"use-wizard.types.d.ts","sourceRoot":"","sources":["../../../src/hooks/use-wizard/use-wizard.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,eAAe,EAChB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6CAA6C,CAAC;AAEpF;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B;;;;;;OAMG;IACH,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,oEAAoE;IACpE,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,WAAW,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,KAAK,EAAE,aAAa,CAAC;QACnB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;KACnC,CAAC,CAAC;IACH,qCAAqC;IACrC,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB;;;;;;;;;;OAUG;IACH,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAAC;IAChD,qEAAqE;IACrE,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;IAE3D,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,IAAI,IAAI,CAAC;IACjB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,4EAA4E;IAC5E,KAAK,IAAI,IAAI,CAAC;CACf"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/use-wizard/use-wizard.types.ts"],"sourcesContent":["import type {\n WizardDefinition,\n WizardState,\n WizardStepStatus,\n WizardStepValue,\n} from '@entifix/core';\n\nimport type { EntityDraftStore } from '../use-entity-form/use-entity-form.types.js';\n\n/**\n * Where a wizard's state is persisted, as the hook sees it.\n *\n * A **port**, and for exactly the reason `EntityDraftStore` is one: `useDraft`\n * and the zustand store behind it are in `@entifix/next-shell` (T4), this\n * package is T3, and `@nx/enforce-module-boundaries` fails the build on the\n * upward edge. `useWizardDraft` in `@entifix/next-shell` is the one adapter.\n *\n * It is a second port rather than a reuse of `EntityDraftStore` because the two\n * carry different shapes — one holds an `EntityDraft`, this holds a whole\n * {@link WizardState} — and a single port widened to both would let a form be\n * handed a wizard's state by a caller that mixed them up.\n */\nexport interface WizardDraftStore {\n /** The persisted wizard, or `undefined` before anything was saved. */\n readonly state?: WizardState;\n /**\n * Persist the wizard. Called on every answered step.\n *\n * Must be referentially stable across renders, for the reason\n * `EntityDraftStore.save` must be: the write happens from an effect keyed on\n * it, and a fresh identity per render turns every render into a write.\n */\n save(state: WizardState): void;\n /**\n * Discard it.\n *\n * **The hook never calls this.** A wizard is spent when its submit is\n * *accepted*, and the hook does not know whether it was — it neither submits\n * nor watches the transaction. The host that owns the mutation calls it, the\n * same division `EntityDraftStore.clear` already draws.\n */\n clear(): void;\n}\n\nexport interface UseWizardOptions {\n definition: WizardDefinition;\n /** Where to persist, so a half-finished flow survives a refresh. */\n draft?: WizardDraftStore;\n /**\n * Steps the caller knows are invalid, overlaid onto the stepper.\n *\n * Supplied rather than derived: a form step's validity lives in its own\n * `useEntityForm` instance, and this hook holds none of them.\n */\n errored?: readonly string[];\n /** Called whenever the active step changes, so a shell can write the URL. */\n onStepChange?: (stepId: string) => void;\n}\n\nexport interface UseWizardResult {\n state: WizardState;\n activeStep: string;\n /** The steps on the projected path, with how each reads. */\n steps: ReadonlyArray<{\n readonly id: string;\n readonly status: WizardStepStatus;\n }>;\n /** The active step ends the flow. */\n canFinish: boolean;\n /**\n * A persisted wizard was picked up mid-flow, so the host owes a recap.\n *\n * True only until the operator moves — resuming is a fact about arriving, and\n * a banner that never goes away stops being read.\n */\n isResumed: boolean;\n\n /**\n * An `EntityDraftStore` viewing one form step's draft, to hand straight to\n * `useEntityForm`.\n *\n * ⚠️ Its `save` is **stable per step id** and its `draft` is the stored\n * reference, so a step that has not changed re-seeds nothing. Building either\n * fresh per render is the write-amplification `useEntityDraft` documents, and\n * a fresh `{}` for an unanswered step is worse — it re-seeds the form every\n * render, which is the `Maximum update depth exceeded` hang ADR 0038 measured.\n */\n draftStoreFor(stepId: string): EntityDraftStore;\n /** Record a non-form step's answer — a choice, a table selection. */\n setStepValue(stepId: string, value: WizardStepValue): void;\n\n next(): void;\n previous(): void;\n goTo(stepId: string): void;\n /** Start over, discarding every answer. The host clears the draft store. */\n reset(): void;\n}\n"],"names":[],"mappings":"AA2DA,WAqCC"}
1
+ {"version":3,"sources":["../../../src/hooks/use-wizard/use-wizard.types.ts"],"sourcesContent":["import type {\n WizardDefinition,\n WizardState,\n WizardStepStatus,\n WizardStepValue,\n} from '@entifix/core';\n\nimport type { EntityDraftStore } from '../use-entity-form/use-entity-form.types.js';\n\n/**\n * Where a wizard's state is persisted, as the hook sees it.\n *\n * A **port**, and for exactly the reason `EntityDraftStore` is one: `useDraft`\n * and the zustand store behind it are in `@entifix/next-shell` (T4), this\n * package is T3, and `@nx/enforce-module-boundaries` fails the build on the\n * upward edge. `useWizardDraft` in `@entifix/next-shell` is the one adapter.\n *\n * It is a second port rather than a reuse of `EntityDraftStore` because the two\n * carry different shapes — one holds an `EntityDraft`, this holds a whole\n * {@link WizardState} — and a single port widened to both would let a form be\n * handed a wizard's state by a caller that mixed them up.\n */\nexport interface WizardDraftStore {\n /** The persisted wizard, or `undefined` before anything was saved. */\n readonly state?: WizardState;\n /**\n * Persist the wizard. Called on every answered step.\n *\n * Must be referentially stable across renders, for the reason\n * `EntityDraftStore.save` must be: the write happens from an effect keyed on\n * it, and a fresh identity per render turns every render into a write.\n */\n save(state: WizardState): void;\n /**\n * Discard it.\n *\n * **The hook never calls this.** A wizard is spent when its submit is\n * *accepted*, and the hook does not know whether it was — it neither submits\n * nor watches the transaction. The host that owns the mutation calls it, the\n * same division `EntityDraftStore.clear` already draws.\n */\n clear(): void;\n}\n\nexport interface UseWizardOptions {\n definition: WizardDefinition;\n /** Where to persist, so a half-finished flow survives a refresh. */\n draft?: WizardDraftStore;\n /**\n * Steps the caller knows are invalid, overlaid onto the stepper.\n *\n * Supplied rather than derived: a form step's validity lives in its own\n * `useEntityForm` instance, and this hook holds none of them.\n */\n errored?: readonly string[];\n /** Called whenever the active step changes, so a shell can write the URL. */\n onStepChange?: (stepId: string) => void;\n}\n\nexport interface UseWizardResult {\n state: WizardState;\n activeStep: string;\n /** The steps on the projected path, with how each reads. */\n steps: ReadonlyArray<{\n readonly id: string;\n readonly status: WizardStepStatus;\n }>;\n /** The active step ends the flow. */\n canFinish: boolean;\n /**\n * A persisted wizard was picked up mid-flow, so the host owes a recap.\n *\n * True only until the operator moves — resuming is a fact about arriving, and\n * a banner that never goes away stops being read.\n */\n isResumed: boolean;\n\n /**\n * An `EntityDraftStore` viewing one form step's draft, to hand straight to\n * `useEntityForm`.\n *\n * ⚠️ Its `save` is **stable per step id** and its `draft` is the stored\n * reference, so a step that has not changed re-seeds nothing. Building either\n * fresh per render is the write-amplification `useEntityDraft` documents, and\n * a fresh `{}` for an unanswered step is worse — it re-seeds the form every\n * render, which is the `Maximum update depth exceeded` hang measured on the\n * master-detail form.\n */\n draftStoreFor(stepId: string): EntityDraftStore;\n /** Record a non-form step's answer — a choice, a table selection. */\n setStepValue(stepId: string, value: WizardStepValue): void;\n\n next(): void;\n previous(): void;\n goTo(stepId: string): void;\n /** Start over, discarding every answer. The host clears the draft store. */\n reset(): void;\n}\n"],"names":[],"mappings":"AA2DA,WAsCC"}
@@ -7,9 +7,9 @@ import type { ReactiveChannel } from './reactive-channel.js';
7
7
  * no headers — so a socket would need a second class of bearer token handed to
8
8
  * client JavaScript, which is the one thing `httpOnly` protects against. An
9
9
  * `EventSource` against the app's own proxy path sends the cookie the way every
10
- * other call in the repo does (ADR 0036).
10
+ * other call in the repo does.
11
11
  *
12
- * What it also gets for free is the half #137 would otherwise hand-write:
12
+ * What it also gets for free is the half a hand-written client would need:
13
13
  * `EventSource` reconnects on its own with backoff and re-sends `Last-Event-ID`.
14
14
  * The server accepts that header and ignores it — the stream is a hint that
15
15
  * something changed, and the record behind it is the truth.
@@ -8,9 +8,9 @@ import { Effect, Result } from 'effect';
8
8
  * no headers — so a socket would need a second class of bearer token handed to
9
9
  * client JavaScript, which is the one thing `httpOnly` protects against. An
10
10
  * `EventSource` against the app's own proxy path sends the cookie the way every
11
- * other call in the repo does (ADR 0036).
11
+ * other call in the repo does.
12
12
  *
13
- * What it also gets for free is the half #137 would otherwise hand-write:
13
+ * What it also gets for free is the half a hand-written client would need:
14
14
  * `EventSource` reconnects on its own with backoff and re-sends `Last-Event-ID`.
15
15
  * The server accepts that header and ignores it — the stream is a hint that
16
16
  * something changed, and the record behind it is the truth.
@@ -1 +1 @@
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"}
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.\n *\n * What it also gets for free is the half a hand-written client would need:\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"}
@@ -7,7 +7,7 @@ export type { EntityChangeEvent };
7
7
  * It receives the whole message, not just the payload: the transaction id, the
8
8
  * emission time and the sequence a consumer needs are `meta.event.correlationId`,
9
9
  * `.at` and `.id`, and duplicating them onto {@link EntityChangeEvent} is how
10
- * two sources of one fact drift (ADR 0036). `#137` settles an optimistic write
10
+ * two sources of one fact drift. `#137` settles an optimistic write
11
11
  * on the correlation id; `useReactiveInvalidation` reads only `data.entity`.
12
12
  */
13
13
  export type EntityChangeListener = (event: DomainEvent<EntityChangeEvent>) => void;
@@ -22,7 +22,7 @@ export interface ReactiveChannel {
22
22
  * Runs whenever the stream is (re)connected — and **immediately if it is
23
23
  * already connected when the listener registers**.
24
24
  *
25
- * The stream does not replay, by design (ADR 0036), so a consumer that missed
25
+ * The stream does not replay, by design, so a consumer that missed
26
26
  * an outcome while disconnected has to go and ask. This is the signal that
27
27
  * tells it when to: `#137` re-queries every pending transaction id here.
28
28
  *
@@ -67,7 +67,7 @@ export interface InMemoryReactiveChannel extends ReactiveChannel {
67
67
  * A synchronous in-memory channel: `emit` fans out to every live subscriber.
68
68
  *
69
69
  * It backs specs. It is no longer what the workspace runs on — that is
70
- * `makeEventSourceReactiveChannel`, and until ADR 0036 landed a transport this
70
+ * `makeEventSourceReactiveChannel`, and until a transport landed this
71
71
  * one was mounted in its place, emitting nothing, so a reader of the workspace
72
72
  * could not tell "the transport is missing" from "nothing changed".
73
73
  */
@@ -15,7 +15,7 @@ export class ReactiveChannelTag extends Context.Service()('ReactiveChannel') {
15
15
  * A synchronous in-memory channel: `emit` fans out to every live subscriber.
16
16
  *
17
17
  * It backs specs. It is no longer what the workspace runs on — that is
18
- * `makeEventSourceReactiveChannel`, and until ADR 0036 landed a transport this
18
+ * `makeEventSourceReactiveChannel`, and until a transport landed this
19
19
  * one was mounted in its place, emitting nothing, so a reader of the workspace
20
20
  * could not tell "the transport is missing" from "nothing changed".
21
21
  */ export function makeInMemoryReactiveChannel() {
@@ -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.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"}
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. `#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, 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 a transport landed 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"}
@@ -38,7 +38,7 @@ export type UseTransactionSettlementOptions = SettlementBase & RecordSource;
38
38
  * - **The stream**, the fast path: `transaction.completed` and
39
39
  * `transaction.failed` carry the correlation id, so an outcome lands in the
40
40
  * moment it happens.
41
- * - **`onConnect`**, because the stream does not replay (ADR 0036). It fires on
41
+ * - **`onConnect`**, because the stream does not replay. It fires on
42
42
  * every open *including for a listener registered while already connected*, so
43
43
  * a page load holding a restored pending set and an in-session reconnect take
44
44
  * one path rather than two.
@@ -1 +1 @@
1
- {"version":3,"file":"use-transaction-settlement.d.ts","sourceRoot":"","sources":["../../src/reactive/use-transaction-settlement.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,uBAAuB,EACvB,iBAAiB,EACjB,uBAAuB,EACxB,MAAM,uBAAuB,CAAC;AAM/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAK7D,UAAU,cAAc;IACtB,8EAA8E;IAC9E,OAAO,EAAE,eAAe,CAAC;IACzB;;;;;;OAMG;IACH,OAAO,EAAE,uBAAuB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,KAAK,YAAY,GACb;IACE,8EAA8E;IAC9E,MAAM,EAAE,uBAAuB,CAAC;IAChC,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,GACD;IACE,8EAA8E;IAC9E,IAAI,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACxE,MAAM,CAAC,EAAE,KAAK,CAAC;CAChB,CAAC;AAEN,MAAM,MAAM,+BAA+B,GAAG,cAAc,GAAG,YAAY,CAAC;AAU5E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,EACvC,OAAO,EACP,OAAO,EACP,cAAgC,EAChC,GAAG,MAAM,EACV,EAAE,+BAA+B,GAAG,IAAI,CA4GxC"}
1
+ {"version":3,"file":"use-transaction-settlement.d.ts","sourceRoot":"","sources":["../../src/reactive/use-transaction-settlement.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,uBAAuB,EACvB,iBAAiB,EACjB,uBAAuB,EACxB,MAAM,uBAAuB,CAAC;AAM/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAK7D,UAAU,cAAc;IACtB,8EAA8E;IAC9E,OAAO,EAAE,eAAe,CAAC;IACzB;;;;;;OAMG;IACH,OAAO,EAAE,uBAAuB,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,KAAK,YAAY,GACb;IACE,8EAA8E;IAC9E,MAAM,EAAE,uBAAuB,CAAC;IAChC,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,GACD;IACE,8EAA8E;IAC9E,IAAI,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACxE,MAAM,CAAC,EAAE,KAAK,CAAC;CAChB,CAAC;AAEN,MAAM,MAAM,+BAA+B,GAAG,cAAc,GAAG,YAAY,CAAC;AAU5E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,EACvC,OAAO,EACP,OAAO,EACP,cAAgC,EAChC,GAAG,MAAM,EACV,EAAE,+BAA+B,GAAG,IAAI,CA6GxC"}
@@ -18,7 +18,7 @@ const readerOf = (source)=>{
18
18
  * - **The stream**, the fast path: `transaction.completed` and
19
19
  * `transaction.failed` carry the correlation id, so an outcome lands in the
20
20
  * moment it happens.
21
- * - **`onConnect`**, because the stream does not replay (ADR 0036). It fires on
21
+ * - **`onConnect`**, because the stream does not replay. It fires on
22
22
  * every open *including for a listener registered while already connected*, so
23
23
  * a page load holding a restored pending set and an in-session reconnect take
24
24
  * one path rather than two.
@@ -103,7 +103,8 @@ const readerOf = (source)=>{
103
103
  if (event.name === 'transaction.failed') {
104
104
  // The frame carries no reason: `EntityChangeEvent` collapses four states
105
105
  // into created/deleted and holds no `error`. So the record is re-read
106
- // rather than the payload widened (ADR 0036's hint-versus-record split).
106
+ // rather than the payload widened: a stream frame is a hint, and the
107
+ // record is the truth.
107
108
  void reconcile(entry);
108
109
  }
109
110
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/reactive/use-transaction-settlement.tsx"],"sourcesContent":["'use client';\n\nimport type {\n PendingEntry,\n PendingTransactionStore,\n TransactionRecord,\n TransactionStatusReader,\n} from '@entifix/transactions';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Effect } from 'effect';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport { entityQueryScopeFor } from '../query/entity-query-key.js';\nimport type { ReactiveChannel } from './reactive-channel.js';\n\n/** How often to re-ask while anything is pending. */\nconst DEFAULT_POLL_MS = 30_000;\n\ninterface SettlementBase {\n /** The stream outcomes arrive on, and whose reconnects trigger a re-query. */\n channel: ReactiveChannel;\n /**\n * The writes being watched.\n *\n * Passed in rather than imported: the store is a shell concern\n * (`@entifix/next-shell`, T4) and this hook is T3, which may not depend\n * upward. Same seam, and the same reason, as `EntityDraftStore` for autosave.\n */\n pending: PendingTransactionStore;\n pollIntervalMs?: number;\n}\n\n/**\n * How a record is read, and exactly one way.\n *\n * `reader` is the port, built once where the host composes its adapters, so the\n * component calling this hook imports no transport. `read` is the same thing as\n * a bare function, for a caller that already holds one.\n */\ntype RecordSource =\n | {\n /** Reads one record. `undefined` means *not tracked yet* — never *failed*. */\n reader: TransactionStatusReader;\n read?: never;\n }\n | {\n /** Reads one record. `undefined` means *not tracked yet* — never *failed*. */\n read: (transactionId: string) => Promise<TransactionRecord | undefined>;\n reader?: never;\n };\n\nexport type UseTransactionSettlementOptions = SettlementBase & RecordSource;\n\nconst readerOf = (\n source: RecordSource,\n): ((transactionId: string) => Promise<TransactionRecord | undefined>) => {\n if (source.reader === undefined) return source.read;\n const { reader } = source;\n return transactionId => Effect.runPromise(reader.read(transactionId));\n};\n\n/**\n * Settles the optimistic writes this browser started.\n *\n * Three things drive it, and they exist for three different failures:\n *\n * - **The stream**, the fast path: `transaction.completed` and\n * `transaction.failed` carry the correlation id, so an outcome lands in the\n * moment it happens.\n * - **`onConnect`**, because the stream does not replay (ADR 0036). It fires on\n * every open *including for a listener registered while already connected*, so\n * a page load holding a restored pending set and an in-session reconnect take\n * one path rather than two.\n * - **An interval**, because `STALE` is written by the recovery sweep and emits\n * no event at all — it is reachable only by asking. It runs only while\n * something is pending, so an idle workspace polls nothing.\n *\n * ⚠️ **A missing record keeps the entry pending.** `accepted` reaches the\n * tracker over the bus, so with the broker down the entity write commits while\n * no event is ever published and the tracker holds nothing. Rolling back there\n * un-renders a healthy write at exactly the moment nobody can tell a UI bug from\n * an outage. Only `FAILED` and `STALE` are terminal.\n */\nexport function useTransactionSettlement({\n channel,\n pending,\n pollIntervalMs = DEFAULT_POLL_MS,\n ...source\n}: UseTransactionSettlementOptions): void {\n const read = readerOf(source);\n const queryClient = useQueryClient();\n\n // The store is rebuilt whenever an entry changes and `read` is typically\n // inline, so both are held in refs: keying the subscription on their identity\n // would tear down and re-open the stream every time a write settled.\n const pendingRef = useRef(pending);\n const readRef = useRef(read);\n\n // Refs may not be written during render. This runs before the effects below,\n // since effects fire in declaration order, so they always see the latest.\n useEffect(() => {\n pendingRef.current = pending;\n readRef.current = read;\n });\n\n const invalidate = useCallback(\n (entity: string) =>\n void queryClient.invalidateQueries({\n queryKey: entityQueryScopeFor(entity),\n }),\n [queryClient],\n );\n\n /**\n * Applies whatever the tracker currently says about one entry.\n *\n * The single place the terminal-state rule lives — the stream path, the\n * reconnect path and the interval all come through here, so there is one\n * answer to \"what does this state mean\" rather than three that can drift.\n */\n const reconcile = useCallback(\n async (entry: PendingEntry): Promise<void> => {\n const record = await readRef.current(entry.transactionId).catch(\n // The tracker being unreachable says nothing about the write. Keeping\n // the entry is what makes a service restart converge instead of dropping\n // every optimistic row on the floor.\n () => undefined,\n );\n\n // ⚠️ Not tracked yet, or still running. Either way it is not over, and\n // this is the branch that must not be turned into a rollback.\n if (record === undefined || record.state === 'PENDING') return;\n\n if (record.state === 'COMPLETED') {\n pendingRef.current.settle(entry.transactionId);\n } else {\n // `record.error` is free text from whatever threw — diagnostic detail\n // beside a translated headline, never the message itself.\n pendingRef.current.fail(entry.transactionId, record.error);\n }\n invalidate(entry.entity);\n },\n [invalidate],\n );\n\n const reconcileAll = useCallback(() => {\n for (const entry of pendingRef.current.entries) {\n if (entry.state === 'pending') void reconcile(entry);\n }\n }, [reconcile]);\n\n useEffect(() => {\n const stopListening = channel.subscribe(event => {\n const transactionId = event.correlationId;\n if (transactionId === undefined) return;\n\n const entry = pendingRef.current.entries.find(\n candidate => candidate.transactionId === transactionId,\n );\n // An id this browser never started belongs to another tab or another\n // session — the stream is organization-scoped, not connection-scoped.\n if (entry === undefined) return;\n\n if (event.name === 'transaction.completed') {\n pendingRef.current.settle(transactionId);\n invalidate(event.data.entity);\n return;\n }\n\n if (event.name === 'transaction.failed') {\n // The frame carries no reason: `EntityChangeEvent` collapses four states\n // into created/deleted and holds no `error`. So the record is re-read\n // rather than the payload widened (ADR 0036's hint-versus-record split).\n void reconcile(entry);\n }\n });\n\n const stopWatchingConnections = channel.onConnect(reconcileAll);\n\n return () => {\n stopListening();\n stopWatchingConnections();\n };\n }, [channel, invalidate, reconcile, reconcileAll]);\n\n // Keyed on whether anything is watched at all, not on the entries: that flips\n // once per transition rather than on every settle, so the timer is not torn\n // down and rebuilt each time a write lands.\n const watching = pending.entries.length > 0;\n\n useEffect(() => {\n if (!watching) return;\n\n const timer = setInterval(reconcileAll, pollIntervalMs);\n return () => clearInterval(timer);\n }, [watching, pollIntervalMs, reconcileAll]);\n}\n"],"names":["useQueryClient","Effect","useCallback","useEffect","useRef","entityQueryScopeFor","DEFAULT_POLL_MS","readerOf","source","reader","undefined","read","transactionId","runPromise","useTransactionSettlement","channel","pending","pollIntervalMs","queryClient","pendingRef","readRef","current","invalidate","entity","invalidateQueries","queryKey","reconcile","entry","record","catch","state","settle","fail","error","reconcileAll","entries","stopListening","subscribe","event","correlationId","find","candidate","name","data","stopWatchingConnections","onConnect","watching","length","timer","setInterval","clearInterval"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/reactive/use-transaction-settlement.tsx"],"sourcesContent":["'use client';\n\nimport type {\n PendingEntry,\n PendingTransactionStore,\n TransactionRecord,\n TransactionStatusReader,\n} from '@entifix/transactions';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Effect } from 'effect';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport { entityQueryScopeFor } from '../query/entity-query-key.js';\nimport type { ReactiveChannel } from './reactive-channel.js';\n\n/** How often to re-ask while anything is pending. */\nconst DEFAULT_POLL_MS = 30_000;\n\ninterface SettlementBase {\n /** The stream outcomes arrive on, and whose reconnects trigger a re-query. */\n channel: ReactiveChannel;\n /**\n * The writes being watched.\n *\n * Passed in rather than imported: the store is a shell concern\n * (`@entifix/next-shell`, T4) and this hook is T3, which may not depend\n * upward. Same seam, and the same reason, as `EntityDraftStore` for autosave.\n */\n pending: PendingTransactionStore;\n pollIntervalMs?: number;\n}\n\n/**\n * How a record is read, and exactly one way.\n *\n * `reader` is the port, built once where the host composes its adapters, so the\n * component calling this hook imports no transport. `read` is the same thing as\n * a bare function, for a caller that already holds one.\n */\ntype RecordSource =\n | {\n /** Reads one record. `undefined` means *not tracked yet* — never *failed*. */\n reader: TransactionStatusReader;\n read?: never;\n }\n | {\n /** Reads one record. `undefined` means *not tracked yet* — never *failed*. */\n read: (transactionId: string) => Promise<TransactionRecord | undefined>;\n reader?: never;\n };\n\nexport type UseTransactionSettlementOptions = SettlementBase & RecordSource;\n\nconst readerOf = (\n source: RecordSource,\n): ((transactionId: string) => Promise<TransactionRecord | undefined>) => {\n if (source.reader === undefined) return source.read;\n const { reader } = source;\n return transactionId => Effect.runPromise(reader.read(transactionId));\n};\n\n/**\n * Settles the optimistic writes this browser started.\n *\n * Three things drive it, and they exist for three different failures:\n *\n * - **The stream**, the fast path: `transaction.completed` and\n * `transaction.failed` carry the correlation id, so an outcome lands in the\n * moment it happens.\n * - **`onConnect`**, because the stream does not replay. It fires on\n * every open *including for a listener registered while already connected*, so\n * a page load holding a restored pending set and an in-session reconnect take\n * one path rather than two.\n * - **An interval**, because `STALE` is written by the recovery sweep and emits\n * no event at all — it is reachable only by asking. It runs only while\n * something is pending, so an idle workspace polls nothing.\n *\n * ⚠️ **A missing record keeps the entry pending.** `accepted` reaches the\n * tracker over the bus, so with the broker down the entity write commits while\n * no event is ever published and the tracker holds nothing. Rolling back there\n * un-renders a healthy write at exactly the moment nobody can tell a UI bug from\n * an outage. Only `FAILED` and `STALE` are terminal.\n */\nexport function useTransactionSettlement({\n channel,\n pending,\n pollIntervalMs = DEFAULT_POLL_MS,\n ...source\n}: UseTransactionSettlementOptions): void {\n const read = readerOf(source);\n const queryClient = useQueryClient();\n\n // The store is rebuilt whenever an entry changes and `read` is typically\n // inline, so both are held in refs: keying the subscription on their identity\n // would tear down and re-open the stream every time a write settled.\n const pendingRef = useRef(pending);\n const readRef = useRef(read);\n\n // Refs may not be written during render. This runs before the effects below,\n // since effects fire in declaration order, so they always see the latest.\n useEffect(() => {\n pendingRef.current = pending;\n readRef.current = read;\n });\n\n const invalidate = useCallback(\n (entity: string) =>\n void queryClient.invalidateQueries({\n queryKey: entityQueryScopeFor(entity),\n }),\n [queryClient],\n );\n\n /**\n * Applies whatever the tracker currently says about one entry.\n *\n * The single place the terminal-state rule lives — the stream path, the\n * reconnect path and the interval all come through here, so there is one\n * answer to \"what does this state mean\" rather than three that can drift.\n */\n const reconcile = useCallback(\n async (entry: PendingEntry): Promise<void> => {\n const record = await readRef.current(entry.transactionId).catch(\n // The tracker being unreachable says nothing about the write. Keeping\n // the entry is what makes a service restart converge instead of dropping\n // every optimistic row on the floor.\n () => undefined,\n );\n\n // ⚠️ Not tracked yet, or still running. Either way it is not over, and\n // this is the branch that must not be turned into a rollback.\n if (record === undefined || record.state === 'PENDING') return;\n\n if (record.state === 'COMPLETED') {\n pendingRef.current.settle(entry.transactionId);\n } else {\n // `record.error` is free text from whatever threw — diagnostic detail\n // beside a translated headline, never the message itself.\n pendingRef.current.fail(entry.transactionId, record.error);\n }\n invalidate(entry.entity);\n },\n [invalidate],\n );\n\n const reconcileAll = useCallback(() => {\n for (const entry of pendingRef.current.entries) {\n if (entry.state === 'pending') void reconcile(entry);\n }\n }, [reconcile]);\n\n useEffect(() => {\n const stopListening = channel.subscribe(event => {\n const transactionId = event.correlationId;\n if (transactionId === undefined) return;\n\n const entry = pendingRef.current.entries.find(\n candidate => candidate.transactionId === transactionId,\n );\n // An id this browser never started belongs to another tab or another\n // session — the stream is organization-scoped, not connection-scoped.\n if (entry === undefined) return;\n\n if (event.name === 'transaction.completed') {\n pendingRef.current.settle(transactionId);\n invalidate(event.data.entity);\n return;\n }\n\n if (event.name === 'transaction.failed') {\n // The frame carries no reason: `EntityChangeEvent` collapses four states\n // into created/deleted and holds no `error`. So the record is re-read\n // rather than the payload widened: a stream frame is a hint, and the\n // record is the truth.\n void reconcile(entry);\n }\n });\n\n const stopWatchingConnections = channel.onConnect(reconcileAll);\n\n return () => {\n stopListening();\n stopWatchingConnections();\n };\n }, [channel, invalidate, reconcile, reconcileAll]);\n\n // Keyed on whether anything is watched at all, not on the entries: that flips\n // once per transition rather than on every settle, so the timer is not torn\n // down and rebuilt each time a write lands.\n const watching = pending.entries.length > 0;\n\n useEffect(() => {\n if (!watching) return;\n\n const timer = setInterval(reconcileAll, pollIntervalMs);\n return () => clearInterval(timer);\n }, [watching, pollIntervalMs, reconcileAll]);\n}\n"],"names":["useQueryClient","Effect","useCallback","useEffect","useRef","entityQueryScopeFor","DEFAULT_POLL_MS","readerOf","source","reader","undefined","read","transactionId","runPromise","useTransactionSettlement","channel","pending","pollIntervalMs","queryClient","pendingRef","readRef","current","invalidate","entity","invalidateQueries","queryKey","reconcile","entry","record","catch","state","settle","fail","error","reconcileAll","entries","stopListening","subscribe","event","correlationId","find","candidate","name","data","stopWatchingConnections","onConnect","watching","length","timer","setInterval","clearInterval"],"mappings":"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"}
package/package.json CHANGED
@@ -21,7 +21,7 @@
21
21
  }
22
22
  }
23
23
  },
24
- "version": "0.5.0",
24
+ "version": "0.6.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": {
@@ -60,16 +60,16 @@
60
60
  "LICENSE"
61
61
  ],
62
62
  "dependencies": {
63
- "@entifix/business": "0.5.0",
64
- "@entifix/core": "0.5.0",
65
- "@entifix/transactions": "0.5.0"
63
+ "@entifix/business": "0.6.0",
64
+ "@entifix/core": "0.6.0",
65
+ "@entifix/transactions": "0.6.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@tanstack/react-form": "1.33.5",
69
69
  "@tanstack/react-query": "^5.104.5",
70
70
  "effect": "4.0.5",
71
71
  "react": "19.3.1",
72
- "@entifix/testing-unit": "0.5.0"
72
+ "@entifix/testing-unit": "0.6.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@tanstack/react-form": ">=1.33 <2",