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.
- package/README.md +3 -1
- package/dist/{ALankaGateway-CkW1LbKE.d.ts → ALankaGateway-BrVPaZN5.d.ts} +1 -1
- package/dist/ILankaFieldError-D5931-vT.d.ts +34 -0
- package/dist/ILankaReadableVM-BoPzHEPV.d.ts +72 -0
- package/dist/{ILankaScenarioMetadata-GoWWNEQL.d.ts → ILankaScenarioMetadata-Dj4GCqmX.d.ts} +1 -1
- package/dist/{ILankaScenarioVM-DUsI-fSc.d.ts → ILankaScenarioVM-DpKFL3iE.d.ts} +5 -4
- package/dist/{LankaError-xpI-qj35.d.ts → LankaError-D6RWNyLM.d.ts} +18 -0
- package/dist/{LankaScenarioLocator-D86TIwiu.d.ts → LankaScenarioLocator-Dki2Es3O.d.ts} +5 -5
- package/dist/{LankaSharedStoreLocator-zS2kLu-S.d.ts → LankaSharedStoreLocator-jsz_gnUm.d.ts} +1 -1
- package/dist/_extend/index.d.ts +233 -9
- package/dist/_extend/index.js +137 -8
- package/dist/_extend/index.js.map +1 -1
- package/dist/_internal/index.d.ts +62 -9
- package/dist/_internal/index.js +34 -3
- package/dist/_internal/index.js.map +1 -1
- package/dist/{activeRuntime-B336NU5I.d.ts → activeRuntime-BO-qY0yY.d.ts} +51 -4
- package/dist/bootstrap/index.d.ts +11 -10
- package/dist/bootstrap/index.js +7 -8
- package/dist/cache/index.d.ts +99 -0
- package/dist/cache/index.js +1 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/{chunk-D27MREPB.js → chunk-7DQUF2QR.js} +2 -2
- package/dist/{chunk-UJEC7H6K.js → chunk-BHQ2SQ7P.js} +2 -2
- package/dist/chunk-CRIRTOLB.js +27 -0
- package/dist/chunk-CRIRTOLB.js.map +1 -0
- package/dist/chunk-EYDMBI5T.js +550 -0
- package/dist/chunk-EYDMBI5T.js.map +1 -0
- package/dist/{chunk-C2HP7CRD.js → chunk-FII3PW2G.js} +2 -2
- package/dist/{chunk-Q7QESSYF.js → chunk-G32H73QY.js} +10 -1
- package/dist/chunk-G32H73QY.js.map +1 -0
- package/dist/{chunk-DTO27QFR.js → chunk-H5TIUKRK.js} +2 -2
- package/dist/chunk-HVSQOMRE.js +69 -0
- package/dist/chunk-HVSQOMRE.js.map +1 -0
- package/dist/{chunk-73IVH3C6.js → chunk-O5EUTNR6.js} +3 -3
- package/dist/{chunk-RKYKK6MN.js → chunk-RUMUFHSL.js} +2 -2
- package/dist/{chunk-G3I7QIZR.js → chunk-SZVNPK24.js} +21 -14
- package/dist/chunk-SZVNPK24.js.map +1 -0
- package/dist/chunk-UDP6IXDS.js +17 -0
- package/dist/chunk-UDP6IXDS.js.map +1 -0
- package/dist/{chunk-5MAQVBI2.js → chunk-UMPSR4US.js} +3 -3
- package/dist/{chunk-BGVDPDX4.js → chunk-XGMXT4XZ.js} +20 -3
- package/dist/chunk-XGMXT4XZ.js.map +1 -0
- package/dist/config/index.js +3 -3
- package/dist/{createLanka-DI1CSy2Q.d.ts → createLanka-x4oSMs7P.d.ts} +2 -2
- package/dist/{createLankaScope-BiFxNQgl.d.ts → createLankaScope-DomNbEvb.d.ts} +1 -1
- package/dist/errors/index.d.ts +22 -3
- package/dist/errors/index.js +10 -5
- package/dist/errors/index.js.map +1 -1
- package/dist/gateway/index.d.ts +4 -3
- package/dist/gateway/index.js +9 -8
- package/dist/gateway/index.js.map +1 -1
- package/dist/index.d.ts +11 -10
- package/dist/index.js +9 -10
- package/dist/{lankaStandardValidator-CL-r-zEV.d.ts → lankaStandardValidator-BUFnysK0.d.ts} +10 -0
- package/dist/locator/index.d.ts +5 -4
- package/dist/locator/index.js +2 -2
- package/dist/logger/index.js +3 -3
- package/dist/mock/index.js +3 -3
- package/dist/scenario/index.d.ts +81 -6
- package/dist/scenario/index.js +6 -8
- package/dist/scenario/index.js.map +1 -1
- package/dist/storage/index.d.ts +120 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/stream/index.d.ts +10 -9
- package/dist/validation/index.d.ts +7 -4
- package/dist/validation/index.js +5 -3
- package/dist/viewmodel/index.d.ts +188 -32
- package/dist/viewmodel/index.js +167 -171
- package/dist/viewmodel/index.js.map +1 -1
- package/package.json +11 -4
- package/skills/lanka-core/SKILL.md +70 -10
- package/skills/lanka-core/reference.md +417 -34
- package/skills/lanka-packages/SKILL.md +94 -22
- package/dist/chunk-BGVDPDX4.js.map +0 -1
- package/dist/chunk-G3I7QIZR.js.map +0 -1
- package/dist/chunk-GV5DUYST.js +0 -64
- package/dist/chunk-GV5DUYST.js.map +0 -1
- package/dist/chunk-O5ROO7QF.js +0 -315
- package/dist/chunk-O5ROO7QF.js.map +0 -1
- package/dist/chunk-Q7QESSYF.js.map +0 -1
- package/dist/chunk-UGXSGQPW.js +0 -180
- package/dist/chunk-UGXSGQPW.js.map +0 -1
- /package/dist/{chunk-D27MREPB.js.map → chunk-7DQUF2QR.js.map} +0 -0
- /package/dist/{chunk-UJEC7H6K.js.map → chunk-BHQ2SQ7P.js.map} +0 -0
- /package/dist/{chunk-C2HP7CRD.js.map → chunk-FII3PW2G.js.map} +0 -0
- /package/dist/{chunk-DTO27QFR.js.map → chunk-H5TIUKRK.js.map} +0 -0
- /package/dist/{chunk-73IVH3C6.js.map → chunk-O5EUTNR6.js.map} +0 -0
- /package/dist/{chunk-RKYKK6MN.js.map → chunk-RUMUFHSL.js.map} +0 -0
- /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@
|
|
3
|
+
> **`lanka@2.0.0`** — this document describes that version.
|
|
4
4
|
>
|
|
5
|
-
> Install: `npm install lanka
|
|
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.
|
|
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
|
|
64
|
-
pnpm add lanka
|
|
65
|
-
yarn add lanka
|
|
66
|
-
bun add lanka
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
477
|
+
import { LankaError, createLankaApiError } from "lanka/errors";
|
|
443
478
|
|
|
444
479
|
try {
|
|
445
480
|
await gateway.list();
|
|
446
481
|
} catch (error) {
|
|
447
|
-
if (error
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|