lanka 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +3 -1
  2. package/dist/{ALankaGateway-CkW1LbKE.d.ts → ALankaGateway-BrVPaZN5.d.ts} +1 -1
  3. package/dist/ILankaFieldError-D5931-vT.d.ts +34 -0
  4. package/dist/ILankaReadableVM-BoPzHEPV.d.ts +72 -0
  5. package/dist/{ILankaScenarioMetadata-GoWWNEQL.d.ts → ILankaScenarioMetadata-Dj4GCqmX.d.ts} +1 -1
  6. package/dist/{ILankaScenarioVM-DUsI-fSc.d.ts → ILankaScenarioVM-DpKFL3iE.d.ts} +5 -4
  7. package/dist/{LankaError-xpI-qj35.d.ts → LankaError-D6RWNyLM.d.ts} +18 -0
  8. package/dist/{LankaScenarioLocator-D86TIwiu.d.ts → LankaScenarioLocator-Dki2Es3O.d.ts} +5 -5
  9. package/dist/{LankaSharedStoreLocator-zS2kLu-S.d.ts → LankaSharedStoreLocator-jsz_gnUm.d.ts} +1 -1
  10. package/dist/_extend/index.d.ts +233 -9
  11. package/dist/_extend/index.js +137 -8
  12. package/dist/_extend/index.js.map +1 -1
  13. package/dist/_internal/index.d.ts +62 -9
  14. package/dist/_internal/index.js +34 -3
  15. package/dist/_internal/index.js.map +1 -1
  16. package/dist/{activeRuntime-B336NU5I.d.ts → activeRuntime-BO-qY0yY.d.ts} +51 -4
  17. package/dist/bootstrap/index.d.ts +11 -10
  18. package/dist/bootstrap/index.js +7 -8
  19. package/dist/cache/index.d.ts +99 -0
  20. package/dist/cache/index.js +1 -0
  21. package/dist/cache/index.js.map +1 -0
  22. package/dist/{chunk-D27MREPB.js → chunk-7DQUF2QR.js} +2 -2
  23. package/dist/{chunk-UJEC7H6K.js → chunk-BHQ2SQ7P.js} +2 -2
  24. package/dist/chunk-CRIRTOLB.js +27 -0
  25. package/dist/chunk-CRIRTOLB.js.map +1 -0
  26. package/dist/chunk-EYDMBI5T.js +550 -0
  27. package/dist/chunk-EYDMBI5T.js.map +1 -0
  28. package/dist/{chunk-C2HP7CRD.js → chunk-FII3PW2G.js} +2 -2
  29. package/dist/{chunk-Q7QESSYF.js → chunk-G32H73QY.js} +10 -1
  30. package/dist/chunk-G32H73QY.js.map +1 -0
  31. package/dist/{chunk-DTO27QFR.js → chunk-H5TIUKRK.js} +2 -2
  32. package/dist/chunk-HVSQOMRE.js +69 -0
  33. package/dist/chunk-HVSQOMRE.js.map +1 -0
  34. package/dist/{chunk-73IVH3C6.js → chunk-O5EUTNR6.js} +3 -3
  35. package/dist/{chunk-RKYKK6MN.js → chunk-RUMUFHSL.js} +2 -2
  36. package/dist/{chunk-G3I7QIZR.js → chunk-SZVNPK24.js} +21 -14
  37. package/dist/chunk-SZVNPK24.js.map +1 -0
  38. package/dist/chunk-UDP6IXDS.js +17 -0
  39. package/dist/chunk-UDP6IXDS.js.map +1 -0
  40. package/dist/{chunk-5MAQVBI2.js → chunk-UMPSR4US.js} +3 -3
  41. package/dist/{chunk-BGVDPDX4.js → chunk-XGMXT4XZ.js} +20 -3
  42. package/dist/chunk-XGMXT4XZ.js.map +1 -0
  43. package/dist/config/index.js +3 -3
  44. package/dist/{createLanka-DI1CSy2Q.d.ts → createLanka-x4oSMs7P.d.ts} +2 -2
  45. package/dist/{createLankaScope-BiFxNQgl.d.ts → createLankaScope-DomNbEvb.d.ts} +1 -1
  46. package/dist/errors/index.d.ts +22 -3
  47. package/dist/errors/index.js +10 -5
  48. package/dist/errors/index.js.map +1 -1
  49. package/dist/gateway/index.d.ts +4 -3
  50. package/dist/gateway/index.js +9 -8
  51. package/dist/gateway/index.js.map +1 -1
  52. package/dist/index.d.ts +11 -10
  53. package/dist/index.js +9 -10
  54. package/dist/{lankaStandardValidator-CL-r-zEV.d.ts → lankaStandardValidator-BUFnysK0.d.ts} +10 -0
  55. package/dist/locator/index.d.ts +5 -4
  56. package/dist/locator/index.js +2 -2
  57. package/dist/logger/index.js +3 -3
  58. package/dist/mock/index.js +3 -3
  59. package/dist/scenario/index.d.ts +81 -6
  60. package/dist/scenario/index.js +6 -8
  61. package/dist/scenario/index.js.map +1 -1
  62. package/dist/storage/index.d.ts +120 -0
  63. package/dist/storage/index.js +1 -0
  64. package/dist/storage/index.js.map +1 -0
  65. package/dist/stream/index.d.ts +10 -9
  66. package/dist/validation/index.d.ts +7 -4
  67. package/dist/validation/index.js +5 -3
  68. package/dist/viewmodel/index.d.ts +188 -32
  69. package/dist/viewmodel/index.js +167 -171
  70. package/dist/viewmodel/index.js.map +1 -1
  71. package/package.json +11 -4
  72. package/skills/lanka-core/SKILL.md +70 -10
  73. package/skills/lanka-core/reference.md +417 -34
  74. package/skills/lanka-packages/SKILL.md +94 -22
  75. package/dist/chunk-BGVDPDX4.js.map +0 -1
  76. package/dist/chunk-G3I7QIZR.js.map +0 -1
  77. package/dist/chunk-GV5DUYST.js +0 -64
  78. package/dist/chunk-GV5DUYST.js.map +0 -1
  79. package/dist/chunk-O5ROO7QF.js +0 -315
  80. package/dist/chunk-O5ROO7QF.js.map +0 -1
  81. package/dist/chunk-Q7QESSYF.js.map +0 -1
  82. package/dist/chunk-UGXSGQPW.js +0 -180
  83. package/dist/chunk-UGXSGQPW.js.map +0 -1
  84. /package/dist/{chunk-D27MREPB.js.map → chunk-7DQUF2QR.js.map} +0 -0
  85. /package/dist/{chunk-UJEC7H6K.js.map → chunk-BHQ2SQ7P.js.map} +0 -0
  86. /package/dist/{chunk-C2HP7CRD.js.map → chunk-FII3PW2G.js.map} +0 -0
  87. /package/dist/{chunk-DTO27QFR.js.map → chunk-H5TIUKRK.js.map} +0 -0
  88. /package/dist/{chunk-73IVH3C6.js.map → chunk-O5EUTNR6.js.map} +0 -0
  89. /package/dist/{chunk-RKYKK6MN.js.map → chunk-RUMUFHSL.js.map} +0 -0
  90. /package/dist/{chunk-5MAQVBI2.js.map → chunk-UMPSR4US.js.map} +0 -0
@@ -1,10 +1,10 @@
1
1
  <!-- Generated from core/GUIDE.md by scripts/skills.mjs. Edit the guide. -->
2
2
 
3
- > **`lanka@1.2.0`** — this document describes that version.
3
+ > **`lanka@2.0.0`** — this document describes that version.
4
4
  >
5
- > Install: `npm install lanka react zustand` (the peers are not optional; only npm adds a missing one for you).
5
+ > Install: `npm install lanka zustand` (the peers are not optional; only npm adds a missing one for you).
6
6
  >
7
- > Complete code, compiled and run in CI: [core/_playground/playground.test.tsx](https://github.com/lankajs/lanka/blob/main/core/_playground/playground.test.tsx)
7
+ > Complete code, compiled and run in CI: [core/_playground/playground.test.ts](https://github.com/lankajs/lanka/blob/main/core/_playground/playground.test.ts)
8
8
 
9
9
  # lanka — user guide
10
10
 
@@ -27,8 +27,31 @@ If you want to know _why_ the framework is shaped this way, read
27
27
  > required: [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md) separates the five things the
28
28
  > machine checks from the many that are advice you can take or leave.
29
29
 
30
+ ## When to reach for this
31
+
32
+ Every other package here is adopted one at a time; this one is the framework, so
33
+ the question is the other way round. Reach for lanka when an application has
34
+ SERVER STATE that several screens care about — a list one screen changes and
35
+ another shows, a session two screens read, a fact that has to travel without one
36
+ screen importing another. That is what the gateway, the ViewModel and the
37
+ scenario bus are for, and it is the shape that gets expensive when it is left to
38
+ grow by itself.
39
+
40
+ Do not reach for it for a form, a landing page, or a screen that loads once and
41
+ sits still: a `useState` and a `fetch` are the right size for those, and this
42
+ framework would be four layers around nothing.
43
+
44
+ It is not an application shell either. Your host framework — Next, React Router,
45
+ TanStack Start, Astro, Expo — keeps routing, rendering and caching;
46
+ [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md#inside-another-framework) draws the line.
47
+
48
+ It binds no UI framework of its own: core imports no UI library at all, and
49
+ reading a ViewModel from a screen is one of the five packages in
50
+ `modules/bindings/`.
51
+
30
52
  ## Contents
31
53
 
54
+ - [When to reach for this](#when-to-reach-for-this)
32
55
  - [Install](#install)
33
56
  - [Your first application](#your-first-application)
34
57
  - [The layers, and which way imports go](#the-layers-and-which-way-imports-go)
@@ -60,16 +83,28 @@ If you want to know _why_ the framework is shaped this way, read
60
83
  ## Install
61
84
 
62
85
  ```bash
63
- npm install lanka react react-dom zustand
64
- pnpm add lanka react react-dom zustand
65
- yarn add lanka react react-dom zustand
66
- bun add lanka react react-dom zustand
86
+ npm install lanka zustand
87
+ pnpm add lanka zustand
88
+ yarn add lanka zustand
89
+ bun add lanka zustand
67
90
  ```
68
91
 
69
- **The peers are listed on purpose.** `react` 19 and `zustand` 5 are peer
70
- dependencies, and only npm installs those for you. Under pnpm, yarn or bun a
71
- missing peer is a warning at install time and a resolution error at build time —
72
- name them once and the question never comes up.
92
+ **The peer is listed on purpose.** `zustand` 5 is core's one peer dependency,
93
+ and only npm installs a missing peer for you. Under pnpm, yarn or bun it is a
94
+ warning at install time and a resolution error at build time — name it once and
95
+ the question never comes up.
96
+
97
+ **No UI framework is in that line, and that is not an omission.** Core imports no
98
+ UI library at all: a ViewModel is a store, a gateway is a function, and neither
99
+ knows what renders. Reading a ViewModel from a screen is one package more, the
100
+ one for your framework:
101
+
102
+ ```bash
103
+ npm install @lankajs/react # or @lankajs/vue, @lankajs/svelte, @lankajs/solid, @lankajs/angular
104
+ ```
105
+
106
+ All five publish the same name — `useLankaVM` — so the guide you read for one
107
+ is the guide for the next.
73
108
 
74
109
  > [!NOTE]
75
110
  > Any package manager works, and nothing in the framework knows which one you
@@ -439,16 +474,29 @@ Every failure that leaves a request is a `LankaError` with a **tagged kind**:
439
474
  | `domain` | your own rule refused | shows your message |
440
475
 
441
476
  ```ts
442
- import { LankaError, createLankaApiError, handleLankaApiError } from "lanka/errors";
477
+ import { LankaError, createLankaApiError } from "lanka/errors";
443
478
 
444
479
  try {
445
480
  await gateway.list();
446
481
  } catch (error) {
447
- if (error instanceof LankaError && error.kind === "aborted") return;
448
- setError(handleLankaApiError(error));
482
+ if (!LankaError.is(error)) throw error; // not ours: let it go up
483
+ if (error.isSilent) return; // aborted — the user has already left
484
+ setError(error.message);
449
485
  }
450
486
  ```
451
487
 
488
+ `error.message` is the sentence to show: your host wrote it for `network`,
489
+ `timeout` and `http`, and your own code wrote it for `domain`. `isSilent` is the
490
+ `aborted` check under a name that says what it is FOR — the interface shows
491
+ nothing, because the person who cancelled knows they did.
492
+
493
+ > [!NOTE]
494
+ > `handleLankaApiError` is **not** this. It takes a `Response`, reads the body
495
+ > once and throws the `LankaError` the rest of the application catches — it is
496
+ > the default `errorHandler` of a request, and the place to pass one of your own:
497
+ > `new LankaFetchJsonRequest({ errorHandler })`. It never returns, so nothing can
498
+ > be assigned from it.
499
+
452
500
  Refuse locally with the same shape rather than a bare `throw`, so a screen has
453
501
  one failure shape to render:
454
502
 
@@ -580,20 +628,22 @@ export const useTodosVM = new TodosVM(gateway).build();
580
628
  The protected surface is exactly the factory's context — `set`, `get`,
581
629
  `gateways`, `services`, `trigger` — and that is enforced, not a convention. The
582
630
  overridable hooks are `states`, `createGateways`, `createServices`,
583
- `scenarioHandlers`, `enhancers`, `onInit`, `onReset` and `createActions`.
631
+ `scenarioHandlers`, `enhancers`, `onInit`, `onReset` and `createActions`. One
632
+ more protected member, `toLifecycleHooks`, is the framework's own reading of
633
+ `onInit` and `onReset` — override those two, never it.
584
634
 
585
635
  ### Config reference
586
636
 
587
- | Field | Meaning |
588
- | ---------------------------------- | --------------------------------------------------- |
589
- | `name` | Shown in logs and devtools. Required |
590
- | `states` | The initial state. Omit for a stateless ViewModel |
591
- | `createActions` | Receives the context, returns the actions |
592
- | `gateways` / `services` | An object or a factory; reachable as `gateways.x` |
593
- | `scenarioHandlers` | `{ scenario, handler }` pairs, bound at bootstrap |
594
- | `enhancers` | Store enhancers, zustand style |
595
- | `onInit` / `onReset` | Lifecycle hooks over the same context |
596
- | `enableAccessTrackingOptimization` | Default on; see [common mistakes](#common-mistakes) |
637
+ | Field | Meaning |
638
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
639
+ | `name` | Shown in logs and devtools. Required |
640
+ | `states` | The initial state. Omit for a stateless ViewModel |
641
+ | `createActions` | Receives the context, returns the actions |
642
+ | `gateways` / `services` | An object or a factory; reachable as `gateways.x` |
643
+ | `scenarioHandlers` | `{ scenario, handler }` pairs, bound at bootstrap |
644
+ | `enhancers` | Store enhancers, zustand style |
645
+ | `onInit` / `onReset` | Lifecycle hooks over the same context. Declaring either enrols the ViewModel with scenario bootstrap: `onInit` runs when bootstrap binds it, `onReset` when the instance — or a lazy ViewModel — is disposed |
646
+ | `enableAccessTrackingOptimization` | Default on; see [common mistakes](#common-mistakes) |
597
647
 
598
648
  ### Using one in a component
599
649
 
@@ -611,6 +661,48 @@ The hook re-renders a component only for the keys it actually **read**. That is
611
661
  usually free performance, and it has one blind spot, described at the end of this
612
662
  guide.
613
663
 
664
+ ### A selector that builds its answer
665
+
666
+ Every binding also takes a selector, and then the selector decides instead of
667
+ the tracking. A selector that **builds** what it answers has a trap in it:
668
+
669
+ ```ts
670
+ useLankaVM(missionVM, (state) => ({ title: state.title, status: state.status }));
671
+ ```
672
+
673
+ That object is new on every call, so it is never identical to the previous one,
674
+ and a binding compares selections by identity. The reader is therefore woken by
675
+ every change in the ViewModel — including the keys the selector exists to
676
+ ignore. A selector answering a **primitive** never has the problem, which is what
677
+ makes this quiet: the shape that is free and the shape that repaints on
678
+ everything look the same on the page.
679
+
680
+ `createLankaShallowHold` is the comparison that fixes it. It answers the
681
+ **previous** object while nothing in the selection moved, one level deep — own
682
+ keys, same count, `Object.is` on each value, arrays included:
683
+
684
+ ```ts
685
+ import { createLankaShallowHold } from "lanka/viewmodel";
686
+
687
+ const hold = createLankaShallowHold<{ title: string; status: string }>();
688
+
689
+ const view = useLankaVM(missionVM, (state) => hold({ title: state.title, status: state.status }));
690
+ ```
691
+
692
+ One hold per reader, created once beside the ViewModel read — never shared
693
+ between two components, because the answer it holds belongs to whoever selected
694
+ it.
695
+
696
+ > [!NOTE]
697
+ > In React, write `useLankaShallow` from `@lankajs/react` instead. It is the same
698
+ > comparison as a hook, and React needs one: a component re-runs the hook on
699
+ > every render, so the holding has to survive a render while the selector stays
700
+ > the current one.
701
+
702
+ A selection with a **nested** object wants a selector that picks the leaves.
703
+ Comparing deeper would mean walking a state of unknown size on every read, which
704
+ is the cost a selector was taken to avoid.
705
+
614
706
  ## Choosing a coordination tool
615
707
 
616
708
  When "A must affect B", one question decides it: **do A and B co-own a thing, or
@@ -770,25 +862,282 @@ Things worth knowing:
770
862
  - **Delivery iterates a copy** of the subscriber list, so a handler may
771
863
  unsubscribe itself mid-delivery. The deliberate consequence: subscribing
772
864
  _during_ delivery waits for the next event.
865
+ - **A failing handler cannot take the dispatch down**, and that holds for an
866
+ async one too. A handler is typed `(data) => void`, but TypeScript assigns a
867
+ `Promise<void>` to a void return position — so `async () => { await refetch();
868
+ }` compiles with nothing to warn about, and "refetch when the stream
869
+ reconnects" is the ordinary shape rather than an exotic one. Its rejection is
870
+ caught and written to the scenario log, not left to surface as an unhandled
871
+ rejection in whatever ran next.
872
+
873
+ What the framework cannot do is decide what the failure MEANT. A log line is a
874
+ diagnostic, not a retry and not a message on a screen — so an action called
875
+ from a handler should still own its own failure, because the handler returns
876
+ `void` and has nowhere to put one.
877
+
773
878
  - **Middleware returns a decision** — `"pass"` or `{ stop: reason }` — never
774
879
  `next()`. A middleware that forgot to call `next()` would make the event vanish
775
880
  silently, and a mechanism that exists for observability must not be its own
776
881
  blind spot. A stop is written to the event log; so is a throw.
777
882
 
883
+ ## Forms
884
+
885
+ lanka ships no form library, the way it ships no router and no cache: React Hook
886
+ Form, TanStack Form and Formik already exist and are better at it. What this
887
+ section settles is the part nobody else can — **who owns what when your
888
+ application brings one**, and what the framework hands it.
889
+
890
+ ### Start without one
891
+
892
+ A ViewModel holds a screen's state, and a form's inputs are state. Give each
893
+ input a key of its **own**:
894
+
895
+ ```ts
896
+ createLankaVM<ILoginState, ILoginActions, { authGateway: AuthGateway }>({
897
+ name: "LoginVM",
898
+ states: { email: "", password: "", fieldErrors: [], screenError: null, isSubmitting: false },
899
+ gateways: () => ({ authGateway }),
900
+ createActions: ({ set }) => ({
901
+ setEmail: (email: string) => set({ email, fieldErrors: [] }),
902
+ // …
903
+ }),
904
+ });
905
+ ```
906
+
907
+ Flat keys matter: the hook tracks the state's **root** keys, so a component
908
+ reading `email` re-renders when `email` moves and not when `password` does. One
909
+ `values: { email, password }` object makes every keystroke everybody's, which is
910
+ the whole cost a form library exists to remove — and here you get it for free.
911
+
912
+ > [!TIP]
913
+ > That is most screens. A sign-in, a filter, a settings page, a create form:
914
+ > independent inputs, validation on submit, no dynamic rows.
915
+
916
+ ### Add a form library when the form grows its own behaviour
917
+
918
+ | Reach for one when | Why the ViewModel stops being enough |
919
+ | ------------------------------------------------------- | ---------------------------------------------- |
920
+ | a field ARRAY — rows the user adds and removes | rows force nesting, and nesting is not tracked |
921
+ | validation on every keystroke | the whole object re-checked per character |
922
+ | one field's validity depends on another's value | written by hand, it drifts |
923
+ | `touched` / `dirty` / blur as behaviour, not decoration | three booleans per input in a screen's state |
924
+ | the screen is server-rendered WITH values | see the warning at the end of this section |
925
+
926
+ Not on the list: how many forms the application has. A big application of flat
927
+ forms needs nothing; one wizard with dynamic rows does.
928
+
929
+ ### The boundary
930
+
931
+ | | Owner |
932
+ | ------------------------------------------------------------ | ------------------------------------------------------------------- |
933
+ | values, `touched`/`dirty`, focus, per-input messages | the **form** |
934
+ | `isSubmitting` | the form — unless it is visible outside the form (a global overlay) |
935
+ | `defaultValues` | the ViewModel: the server's version, held as `server` |
936
+ | calling the gateway, `trigger`, clearing a draft, navigating | the **ViewModel** |
937
+ | an input's ASYNCHRONOUS check | a ViewModel action — a resolver may not call a gateway |
938
+ | failures with an address | pass through the ViewModel to the form |
939
+ | failures without one | the ViewModel's own state |
940
+
941
+ The last row is the interesting one, and the two before it are why a form cannot
942
+ be left to talk to the network itself.
943
+
944
+ ### One schema, three readers
945
+
946
+ The validation port speaks [Standard Schema](https://standardschema.dev), and so
947
+ do React Hook Form (through `@hookform/resolvers`) and TanStack Form. **The same
948
+ schema object is the form's resolver and the gateway's payload check** — there is
949
+ no adapter to write and nothing declared twice.
950
+
951
+ Keep the INPUT schema apart from the response ones. A form given the response
952
+ schema asks for an `id` and an `updatedAt` the user does not have:
953
+
954
+ | Schema | Read by | Changes when |
955
+ | ------------ | ------------------------------------------------------- | ------------------------------ |
956
+ | `orderWire` | the gateway, on the way in | the backend changes |
957
+ | `order` | the gateway, the domain check | the application does |
958
+ | `orderInput` | **the form's resolver AND the gateway's payload check** | what a person may type changes |
959
+
960
+ ### A failure that knows which input it belongs to
961
+
962
+ `LankaError.issues` is a flat list for a banner — `items.1.qty: only 2 left`. A
963
+ form needs the address instead of the sentence, so a failure also carries
964
+ `fields`, and `readLankaFieldErrors` reads them off anything:
965
+
966
+ ```ts
967
+ import { readLankaFieldErrors } from "lanka/errors";
968
+ // → readonly ILankaFieldError[]: { path: ["items", 1, "qty"], message, code? }
969
+ ```
970
+
971
+ The path stays in **segments**. React Hook Form writes that address
972
+ `items.1.qty`, TanStack Form writes `items[1].qty`, and a joined string cannot be
973
+ taken apart again — a message may contain a colon, a key may contain a dot. An
974
+ **empty** path is the value as a whole: a cross-field refusal ("the dates are in
975
+ the wrong order"), which belongs to the form's root and not to an input named
976
+ `""`.
977
+
978
+ `lankaStandardValidator` fills `fields` from a schema's issues. A server's `422`
979
+ is your backend's shape, so the gateway — or `@lankajs/plugin-http`, where body
980
+ parsing is configured once — turns it into the same list.
981
+
982
+ ### Which failure goes where
983
+
984
+ The kinds already say what each demands of an interface; a submit action reads
985
+ them and answers the form. Write this once and call it from every submit:
986
+
987
+ ```ts
988
+ type TSubmitOutcome<TData> =
989
+ | { ok: true; data: TData }
990
+ | { ok: false; fields: readonly ILankaFieldError[]; message?: string };
991
+
992
+ const sortFailure = (error: unknown, toScreen: (m: string) => void): TSubmitOutcome<never> => {
993
+ if (!LankaError.is(error)) throw error;
994
+ if (error.isSilent) return { ok: false, fields: [] }; // aborted: the user left
995
+
996
+ const fields = readLankaFieldErrors(error);
997
+ if (fields.length > 0) return { ok: false, fields }; // it has an address
998
+ if (error.kind === "domain") return { ok: false, fields: [], message: error.message };
999
+
1000
+ toScreen(error.message); // network, timeout, 5xx, schema — the screen's
1001
+ return { ok: false, fields: [] };
1002
+ };
1003
+ ```
1004
+
1005
+ The framework publishes the reading (`readLankaFieldErrors`) and leaves the
1006
+ sorting to you, because which refusal your application shows under an input is
1007
+ your decision, not a framework's.
1008
+
1009
+ **The form never sees a `LankaError`.** It does not know what a transport is.
1010
+
1011
+ ### The order after a save succeeds
1012
+
1013
+ ```ts
1014
+ const order = await gateways.orderGateway.update(id, values);
1015
+
1016
+ set({ server: order, serverChangedAt: null }); // 1. mark your own write
1017
+ trigger(orderUpdated, { order }); // 2. announce it, WITH the data
1018
+ services.cache?.write(["order", id], order); // 3. tell a cache, if you have one
1019
+ return { ok: true, data: order }; // 4. the form is still mounted here
1020
+ // 5. navigate — after this returns
1021
+ ```
1022
+
1023
+ Step 1 before step 2 is the rule: a handler that hears its own save must
1024
+ recognise it and stay quiet. Compare by `id` and a version, never by reference.
1025
+ Step 4 before step 5 so the form is alive to be told.
1026
+
1027
+ ### While the form is open, nothing writes into it
1028
+
1029
+ A scenario or a push can arrive mid-edit. **A scenario handler must not touch the
1030
+ fields** — it replaces the server's version and MARKS it:
1031
+
1032
+ ```ts
1033
+ scenarioHandlers: [
1034
+ {
1035
+ scenario: orderUpdated,
1036
+ handler: ({ get, set }) => (data) => {
1037
+ if (!data || data.order.updatedAt === get().server?.updatedAt) return;
1038
+ set({ server: data.order, serverChangedAt: data.order.updatedAt });
1039
+ },
1040
+ },
1041
+ ],
1042
+ ```
1043
+
1044
+ The screen then offers "this changed — reload", and the person decides. Resetting
1045
+ the form automatically erases what they were typing; ignoring the change hands
1046
+ them a conflict on save. The same rule holds when the inputs live in the
1047
+ ViewModel, where breaking it is merely easier.
1048
+
1049
+ ### The adapter, per library
1050
+
1051
+ Ten lines, written once per application, and the only thing in them that is
1052
+ library-specific is how it spells an address.
1053
+
1054
+ **React Hook Form** — the resolver is your schema:
1055
+
1056
+ ```tsx
1057
+ const form = useForm({ defaultValues: initial, resolver: standardSchemaResolver(orderInput) });
1058
+
1059
+ const onSubmit = form.handleSubmit(async (values) => {
1060
+ const outcome = await submit(values);
1061
+ if (outcome.ok) return form.reset(values);
1062
+
1063
+ for (const field of outcome.fields) {
1064
+ form.setError(field.path.length === 0 ? "root" : field.path.join("."), {
1065
+ message: field.message,
1066
+ });
1067
+ }
1068
+ if (outcome.message) form.setError("root", { message: outcome.message });
1069
+ });
1070
+ ```
1071
+
1072
+ **TanStack Form** — reads Standard Schema itself, and the server's answer is the
1073
+ return value of an async submit validator:
1074
+
1075
+ ```ts
1076
+ useForm({
1077
+ defaultValues: initial,
1078
+ validators: {
1079
+ onSubmit: orderInput,
1080
+ onSubmitAsync: async ({ value }) => {
1081
+ const outcome = await submit(value);
1082
+ if (outcome.ok) return undefined;
1083
+
1084
+ return {
1085
+ form: outcome.message,
1086
+ fields: Object.fromEntries(
1087
+ outcome.fields
1088
+ .filter((field) => field.path.length > 0)
1089
+ .map((field) => [toBracketAddress(field.path), field.message]),
1090
+ ),
1091
+ };
1092
+ },
1093
+ },
1094
+ });
1095
+ ```
1096
+
1097
+ **Formik** — no Standard Schema, and it does not need one: the port already
1098
+ answered `fields`, and Formik's nested error object folds from them.
1099
+
1100
+ ```ts
1101
+ validate: (values) => {
1102
+ const checked = lankaStandardValidator.validateSafe(orderInput, values);
1103
+ if (checked.success) return {};
1104
+
1105
+ return (checked.fields ?? []).reduce(
1106
+ (errors, field) => setIn(errors, field.path.join("."), field.message),
1107
+ {},
1108
+ );
1109
+ },
1110
+ ```
1111
+
1112
+ A hand-written form implements the same two shapes and takes no dependency on
1113
+ lanka at all: a Standard Schema in, `ILankaFieldError[]` out.
1114
+
1115
+ > [!WARNING]
1116
+ > **Under SSR, form values must not live in a ViewModel.** A ViewModel is a store
1117
+ > created at module level — one per PROCESS, which on a server is one shared by
1118
+ > every request — and `hydrateLankaVM` applies once per store. An empty form is
1119
+ > fine; an edit form pre-filled on the server is not. Use a form library there,
1120
+ > or `useState` in the component with the values passed to the action.
1121
+
1122
+ > [!NOTE]
1123
+ > Moving a screen from the first shape to the second changes two things: where
1124
+ > the values live, and that the action takes them as an argument. The schema, the
1125
+ > gateway, the failure sorting and the scenarios are untouched.
1126
+
778
1127
  ## Streams — a change that arrives from the server
779
1128
 
780
1129
  `lanka/stream` is the protocol-free half of realtime: what is the same whether
781
1130
  the wire is server-sent events, a WebSocket, a GraphQL subscription or a gRPC
782
1131
  server stream.
783
1132
 
784
- | Name | What it is |
785
- | --------------------------------- | ---------------------------------------------------------------- |
1133
+ | Name | What it is |
1134
+ | --------------------------------- | --------------------------------------------------------------------- |
786
1135
  | `ILankaServerEventTransport` | the port: `isSupported`, `connect`, `disconnect`, `on`, `onReconnect` |
787
- | `ALankaStreamBridge` | a wire event → one of your scenarios |
788
- | `createLankaStreamBridge` | the same, written by calling |
789
- | `createLankaStreamTriggerContext` | the "this came from outside" marker |
790
- | `ALankaStreamTransport` | the base a transport extends: dispatch, and the reconnect ladder |
791
- | `lankaStream` | the plugin: bridges attached, lifetime owned |
1136
+ | `ALankaStreamBridge` | a wire event → one of your scenarios |
1137
+ | `createLankaStreamBridge` | the same, written by calling |
1138
+ | `createLankaStreamTriggerContext` | the "this came from outside" marker |
1139
+ | `ALankaStreamTransport` | the base a transport extends: dispatch, and the reconnect ladder |
1140
+ | `lankaStream` | the plugin: bridges attached, lifetime owned |
792
1141
 
793
1142
  You normally reach these through a protocol package —
794
1143
  [`@lankajs/plugin-sse`](https://github.com/lankajs/lanka/blob/main/plugins/sse/GUIDE.md),
@@ -831,7 +1180,7 @@ the backoff ladder with its attempt ceiling and one auth refresh, and
831
1180
  `onReconnect`. A subclass writes `open` and `close`, and reports through three
832
1181
  handlers — `opened`, `received`, `lost`. Nothing else, and deliberately: the bug
833
1182
  this shape is prone to is resetting the attempt counter where reconnection
834
- *starts* rather than where it *succeeds*, after which the ceiling exists, reads
1183
+ _starts_ rather than where it _succeeds_, after which the ceiling exists, reads
835
1184
  as a guard, and can never fire.
836
1185
 
837
1186
  ## The locator
@@ -1038,7 +1387,7 @@ npx lanka-skills sync
1038
1387
 
1039
1388
  ### The modules and plugins
1040
1389
 
1041
- Twenty-one more packages solve problems you may or may not have — four wire
1390
+ Thirty-seven more packages solve problems you may or may not have — four wire
1042
1391
  protocols, retry policy, optimistic updates, list handling, storage,
1043
1392
  prefetching. The table of
1044
1393
  "add it when" is in [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md#adopting-the-packages),
@@ -1137,6 +1486,40 @@ runtime. `startLanka` — or `createLanka` — comes first.
1137
1486
  or the ViewModel was constructed after it: bootstrap binds what exists when it
1138
1487
  runs.
1139
1488
 
1489
+ **A scenario handler from an earlier TEST that fires when it should not.** The
1490
+ mirror image, and it only appears in test suites that build ViewModels inside
1491
+ test bodies rather than at module level.
1492
+
1493
+ A ViewModel declares itself to the scenario layer when it is built, and the
1494
+ declaration deliberately outlives the instance: the next `createLanka` has to
1495
+ re-adopt every module-level ViewModel, or a second instance in one process would
1496
+ know none of them.
1497
+
1498
+ So `lankaScenarioBootstrap.reset()` clears subscriptions but NOT declarations,
1499
+ and the next `bootstrap()` re-adopts everything ever built in that process. A
1500
+ ViewModel from a finished test hears the next test's facts and runs its handlers
1501
+ against the gateway IT was built with. The symptom is never "a stale subscriber":
1502
+ it is one extra call on a double, or a rejection surfacing in a test that already
1503
+ passed.
1504
+
1505
+ ```ts
1506
+ beforeEach(() => {
1507
+ lankaScenarioBootstrap.reset({ withDeclarations: true });
1508
+ });
1509
+ ```
1510
+
1511
+ That is the fix, and it is opt-in on purpose: the default is what an application
1512
+ needs, and a suite whose ViewModels all live at module level should keep it —
1513
+ forgetting them would leave their handlers bound to nothing. Forgetting is not a
1514
+ tombstone, so a ViewModel declared again afterwards is adopted again.
1515
+
1516
+ Two habits make a suite immune even where a reset is missed:
1517
+
1518
+ - give each ViewModel its own double, so a stale one calling its own mock cannot
1519
+ disturb the counts a live test asserts on;
1520
+ - let an action own its failure rather than rejecting at a handler — a handler
1521
+ returns `void` and has nowhere to put a rejection.
1522
+
1140
1523
  **Reading state through a getter and wondering why the screen froze.** A consumer
1141
1524
  re-renders only for the keys it _read through the proxy_. If a component's only
1142
1525
  link to `todos` goes through a getter that calls `get()` internally, that read