lanka 1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -0
  3. package/dist/ALankaGateway-ExlRGT3D.d.ts +122 -0
  4. package/dist/ALankaSharedStore-B7uepuuk.d.ts +29 -0
  5. package/dist/ILankaApiError-zI_2bNs0.d.ts +16 -0
  6. package/dist/ILankaRuntimeConfig-Vl436GWK.d.ts +62 -0
  7. package/dist/ILankaScenarioMetadata-Bu-yggTZ.d.ts +14 -0
  8. package/dist/ILankaScenarioVM-DuCyPoyT.d.ts +260 -0
  9. package/dist/LankaError-B1HtuIkw.d.ts +86 -0
  10. package/dist/LankaScenarioLocator-BGQHwf3n.d.ts +144 -0
  11. package/dist/LankaSharedStoreLocator-MvCpav5F.d.ts +126 -0
  12. package/dist/TLankaErrorHandler-Yfqtdh1M.d.ts +3 -0
  13. package/dist/_extend/index.d.ts +34 -0
  14. package/dist/_extend/index.js +36 -0
  15. package/dist/_extend/index.js.map +1 -0
  16. package/dist/_internal/index.d.ts +43 -0
  17. package/dist/_internal/index.js +34 -0
  18. package/dist/_internal/index.js.map +1 -0
  19. package/dist/activeRuntime-FcsSJvUg.d.ts +84 -0
  20. package/dist/bootstrap/index.d.ts +242 -0
  21. package/dist/bootstrap/index.js +22 -0
  22. package/dist/bootstrap/index.js.map +1 -0
  23. package/dist/chunk-73IVH3C6.js +52 -0
  24. package/dist/chunk-73IVH3C6.js.map +1 -0
  25. package/dist/chunk-BGVDPDX4.js +29 -0
  26. package/dist/chunk-BGVDPDX4.js.map +1 -0
  27. package/dist/chunk-C2HP7CRD.js +224 -0
  28. package/dist/chunk-C2HP7CRD.js.map +1 -0
  29. package/dist/chunk-D27MREPB.js +13 -0
  30. package/dist/chunk-D27MREPB.js.map +1 -0
  31. package/dist/chunk-DTO27QFR.js +59 -0
  32. package/dist/chunk-DTO27QFR.js.map +1 -0
  33. package/dist/chunk-EWVDJYCC.js +296 -0
  34. package/dist/chunk-EWVDJYCC.js.map +1 -0
  35. package/dist/chunk-FIR4XTBL.js +136 -0
  36. package/dist/chunk-FIR4XTBL.js.map +1 -0
  37. package/dist/chunk-ILQNYQY5.js +200 -0
  38. package/dist/chunk-ILQNYQY5.js.map +1 -0
  39. package/dist/chunk-J5LA3Q6D.js +14 -0
  40. package/dist/chunk-J5LA3Q6D.js.map +1 -0
  41. package/dist/chunk-MDZ42XRH.js +10 -0
  42. package/dist/chunk-MDZ42XRH.js.map +1 -0
  43. package/dist/chunk-Q3SOVBIJ.js +176 -0
  44. package/dist/chunk-Q3SOVBIJ.js.map +1 -0
  45. package/dist/chunk-Q7QESSYF.js +49 -0
  46. package/dist/chunk-Q7QESSYF.js.map +1 -0
  47. package/dist/chunk-RKYKK6MN.js +19 -0
  48. package/dist/chunk-RKYKK6MN.js.map +1 -0
  49. package/dist/chunk-RYFZCAQ3.js +637 -0
  50. package/dist/chunk-RYFZCAQ3.js.map +1 -0
  51. package/dist/chunk-UJEC7H6K.js +39 -0
  52. package/dist/chunk-UJEC7H6K.js.map +1 -0
  53. package/dist/chunk-XESL274R.js +23 -0
  54. package/dist/chunk-XESL274R.js.map +1 -0
  55. package/dist/chunk-YR4MZXMU.js +13 -0
  56. package/dist/chunk-YR4MZXMU.js.map +1 -0
  57. package/dist/config/index.d.ts +63 -0
  58. package/dist/config/index.js +16 -0
  59. package/dist/config/index.js.map +1 -0
  60. package/dist/createLankaScope-Bc_vChRs.d.ts +30 -0
  61. package/dist/errors/index.d.ts +37 -0
  62. package/dist/errors/index.js +16 -0
  63. package/dist/errors/index.js.map +1 -0
  64. package/dist/gateway/index.d.ts +258 -0
  65. package/dist/gateway/index.js +438 -0
  66. package/dist/gateway/index.js.map +1 -0
  67. package/dist/index.d.ts +20 -0
  68. package/dist/index.js +43 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/lankaHttpInFlight-Bk1eIuSx.d.ts +43 -0
  71. package/dist/lankaRequestMiddleware-DAC5kCb7.d.ts +62 -0
  72. package/dist/lankaStandardValidator-CL-r-zEV.d.ts +34 -0
  73. package/dist/locator/index.d.ts +282 -0
  74. package/dist/locator/index.js +67 -0
  75. package/dist/locator/index.js.map +1 -0
  76. package/dist/logger/index.d.ts +146 -0
  77. package/dist/logger/index.js +11 -0
  78. package/dist/logger/index.js.map +1 -0
  79. package/dist/mock/index.d.ts +16 -0
  80. package/dist/mock/index.js +58 -0
  81. package/dist/mock/index.js.map +1 -0
  82. package/dist/role/index.d.ts +48 -0
  83. package/dist/role/index.js +7 -0
  84. package/dist/role/index.js.map +1 -0
  85. package/dist/scenario/index.d.ts +204 -0
  86. package/dist/scenario/index.js +28 -0
  87. package/dist/scenario/index.js.map +1 -0
  88. package/dist/validation/index.d.ts +18 -0
  89. package/dist/validation/index.js +53 -0
  90. package/dist/validation/index.js.map +1 -0
  91. package/dist/viewmodel/index.d.ts +478 -0
  92. package/dist/viewmodel/index.js +701 -0
  93. package/dist/viewmodel/index.js.map +1 -0
  94. package/package.json +99 -0
  95. package/skills/lanka-core/SKILL.md +241 -0
  96. package/skills/lanka-core/reference.md +1090 -0
  97. package/skills/lanka-packages/SKILL.md +70 -0
@@ -0,0 +1,1090 @@
1
+ <!-- Generated from core/GUIDE.md by scripts/skills.mjs. Edit the guide. -->
2
+
3
+ > **`lanka@1.0.0`** — this document describes that version.
4
+ >
5
+ > Install: `npm install lanka react zustand` (the peers are not optional; only npm adds a missing one for you).
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)
8
+
9
+ # lanka — user guide
10
+
11
+ The core package. Everything in this guide works with `lanka` alone; modules and
12
+ plugins are optional and have guides of their own.
13
+
14
+ If you want to know _why_ the framework is shaped this way, read
15
+ [README.md](https://github.com/lankajs/lanka/blob/main/core/README.md). This file is about _using_ it.
16
+
17
+ ## You will learn
18
+
19
+ - how to start the framework in one line, and what that line does
20
+ - the four layers, and the one direction imports go
21
+ - how a screen gets data: gateway → ViewModel → view
22
+ - how two screens reach each other without importing each other
23
+ - what fails loudly on purpose, and why
24
+
25
+ > [!NOTE]
26
+ > This guide shows how lanka is _meant_ to be used. Very little of it is
27
+ > required: [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md) separates the five things the
28
+ > machine checks from the many that are advice you can take or leave.
29
+
30
+ ## Contents
31
+
32
+ - [Install](#install)
33
+ - [Your first application](#your-first-application)
34
+ - [The layers, and which way imports go](#the-layers-and-which-way-imports-go)
35
+ - [The instance](#the-instance)
36
+ - [The host contract](#the-host-contract)
37
+ - [Flags](#flags)
38
+ - [Gateways — talking to a server](#gateways--talking-to-a-server)
39
+ - [Failure](#failure)
40
+ - [Validating a response](#validating-a-response)
41
+ - [ViewModels](#viewmodels)
42
+ - [Choosing a coordination tool](#choosing-a-coordination-tool)
43
+ - [Shared stores](#shared-stores)
44
+ - [Stateless ViewModels](#stateless-viewmodels)
45
+ - [Lazy ViewModels](#lazy-viewmodels)
46
+ - [Scenarios — cross-screen facts](#scenarios--cross-screen-facts)
47
+ - [The locator](#the-locator)
48
+ - [Scopes](#scopes)
49
+ - [Mock mode](#mock-mode)
50
+ - [The logger](#the-logger)
51
+ - [Plugins](#plugins)
52
+ - [Roles — both styles for your own layer](#roles--both-styles-for-your-own-layer)
53
+ - [Import map](#import-map)
54
+ - [What else ships](#what-else-ships)
55
+ - [Cases](#cases)
56
+ - [Common mistakes](#common-mistakes)
57
+ - [Recap](#recap)
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ npm install lanka react react-dom zustand
63
+ pnpm add lanka react react-dom zustand
64
+ yarn add lanka react react-dom zustand
65
+ bun add lanka react react-dom zustand
66
+ ```
67
+
68
+ **The peers are listed on purpose.** `react` 19 and `zustand` 5 are peer
69
+ dependencies, and only npm installs those for you. Under pnpm, yarn or bun a
70
+ missing peer is a warning at install time and a resolution error at build time —
71
+ name them once and the question never comes up.
72
+
73
+ > [!NOTE]
74
+ > Any package manager works, and nothing in the framework knows which one you
75
+ > used. The one place they differ is the line above; the second is how you run a
76
+ > package's binary, which [`@lankajs/tool-skills`](https://github.com/lankajs/lanka/blob/main/tools/skills/GUIDE.md#install)
77
+ > spells out for each.
78
+
79
+ TypeScript is not required, but every API is written for it and the types are the
80
+ documentation of last resort.
81
+
82
+ ## Your first application
83
+
84
+ One line:
85
+
86
+ ```ts
87
+ import { startLanka } from "lanka";
88
+
89
+ const lanka = await startLanka({ apiBaseUrl: "https://api.example.com" });
90
+ ```
91
+
92
+ That creates the instance, activates it, installs any plugins and awaits
93
+ bootstrap.
94
+
95
+ **Every field is optional, `apiBaseUrl` included.** `await startLanka()` is a
96
+ working application: with no base URL a gateway's paths are used as written,
97
+ which is what you want when the app is served from its API's origin, or when it
98
+ talks to several APIs and each gateway names its own. What the framework will
99
+ never do is guess a default like `/api` — that would be silently wrong for
100
+ everyone who does not use it, and the symptom is a 404 three layers from the
101
+ cause.
102
+
103
+ Everything else is a field on the same call:
104
+
105
+ ```ts
106
+ const lanka = await startLanka({
107
+ apiBaseUrl: import.meta.env.VITE_API_URL,
108
+ messages: { networkErrorMessage: () => "No connection" },
109
+ flags: { isDevelopment: import.meta.env.DEV },
110
+ plugins: [lankaHttp(lankaTokenSessionPolicy({ auth }))],
111
+ services: [{ name: "session", init: () => restoreSession() }],
112
+ });
113
+ ```
114
+
115
+ Pass `host` instead of `apiBaseUrl` when the copy is yours from the start —
116
+ `ILankaHost` requires all four members, so a missing translator is a compile
117
+ error rather than an untranslated string in somebody's interface.
118
+
119
+ ### Starting inside Next, React Router or Expo
120
+
121
+ `startLanka` starts ONE instance, which is exactly right in a browser and wrong
122
+ on a server: a process there serves many users, and one instance would be shared
123
+ between them. If your host framework renders on a server, the instance is created
124
+ per request instead — `runLankaRequest` and `runLankaStatic` in
125
+ [@lankajs/host](https://github.com/lankajs/lanka/blob/main/modules/host/GUIDE.md) — and this call stays for the browser
126
+ half. The two do not conflict; they answer for different sides of the network.
127
+
128
+ ### When one line is not enough
129
+
130
+ `startLanka` is `createLanka` and `bootstrap` in the order that works. Write
131
+ them out when something has to happen between the two — registering a singleton
132
+ whose construction reads a service's result, say:
133
+
134
+ ```ts
135
+ const lanka = createLanka({ host, flags }); // already active from here
136
+ lanka.locators.singletons.register("SessionService", SessionService);
137
+ await lanka.bootstrap({ services });
138
+ ```
139
+
140
+ Two things are worth knowing whichever way you start:
141
+
142
+ 1. **`createLanka()` activates the instance itself.** `lanka.activate()` exists
143
+ for the case with two instances in one process — a test beside the app — where
144
+ you say which one is ambient. A single-instance application never calls it.
145
+ 2. **`bootstrap()` brings up the scenario layer.** You do not call
146
+ `lankaScenarioBootstrap` yourself: bootstrap does it, after the async services
147
+ by default and in the ordered phase under `scenarios: { sync: true }`.
148
+
149
+ What must be in order: register a name before constructing whatever resolves it,
150
+ and let ViewModels with `scenarioHandlers` exist before bootstrap runs — it binds
151
+ what exists when it runs.
152
+
153
+ In React, do this once at the module top level of your entry file, or in an
154
+ effect in a root component that also calls `lanka.dispose()` on unmount.
155
+
156
+ ## The layers, and which way imports go
157
+
158
+ ```
159
+ View (React) renders. Reads one hook and nothing else.
160
+
161
+
162
+ ViewModel owns state and actions. Calls gateways, triggers scenarios.
163
+
164
+
165
+ Gateway states endpoints. No state, no error policy.
166
+
167
+
168
+ Request / Transport what a response IS, and how bytes travel.
169
+ ```
170
+
171
+ **Imports go one way.** A ViewModel may reach a gateway; a gateway does not know
172
+ ViewModels exist. This is not an agreement — [`@lankajs/tool-eslint`](https://github.com/lankajs/lanka/blob/main/tools/eslint/GUIDE.md)
173
+ names the file and the line.
174
+
175
+ | Layer | May use | Must never |
176
+ | ------------------- | --------------------------------------------- | -------------------------------------------- |
177
+ | View | its ViewModel's hook | import a gateway; own loading or retry state |
178
+ | ViewModel | gateways, services, scenarios, a shared store | import another ViewModel |
179
+ | Gateway | its request, a validator, a mock handler | import another gateway; hold state |
180
+ | Request / Transport | `fetch`, or whatever you supply | know an endpoint or a domain type |
181
+
182
+ Two things sit **across** the layers rather than inside one:
183
+
184
+ - **Scenarios** are how two ViewModels reach each other without importing
185
+ anything. One triggers a fact; the others subscribe.
186
+ - **The locator** holds singletons, gateways, shared stores and scenarios by
187
+ name, so a screen asks for a name and never learns where the object came from.
188
+
189
+ And one thing points **inward**, from your application into the framework: the
190
+ `.lanka_di/` barrels. That is the single permitted inversion, and it holds
191
+ because there is exactly one reading side — the framework. Your own code
192
+ resolves through the locator instead.
193
+
194
+ ### Deciding where a new file goes
195
+
196
+ Three questions, and the answer to all three is usually forced:
197
+
198
+ **① What kind of work does it do?**
199
+
200
+ | The work | Where it lives |
201
+ | -------------------------------------- | -------------- |
202
+ | talks to a server, a storage, an SDK | a gateway |
203
+ | remembers something, decides something | a ViewModel |
204
+ | draws the screen | a component |
205
+ | pure input → output, no memory | a plain helper |
206
+ | announces "something happened" | a scenario |
207
+
208
+ One file doing two of these is two files in a trench coat.
209
+
210
+ **② Who may use it?** Pick the smallest scope that fits today: one component →
211
+ one screen → one feature → the whole app.
212
+
213
+ **③ Which way does it point?** Downward, toward the more general. A screen may
214
+ use a helper; a helper may never use a screen.
215
+
216
+ ## The instance
217
+
218
+ `createLanka()` returns an `ILankaInstance`, and it owns **all** framework state:
219
+ the event bus, the scenario registries, four locator caches, the config and the
220
+ in-flight request counter.
221
+
222
+ | Method | What it does |
223
+ | -------------------------- | ------------------------------------------------------ |
224
+ | `activate()` | Makes this instance the one ambient facades resolve |
225
+ | `bootstrap(config?)` | Runs services and the scenario layer. Idempotent |
226
+ | `isBootstrapped()` | Has it run yet |
227
+ | `resolve<T>(name)` | Resolves a service in the root scope |
228
+ | `createScope()` | A lifetime shorter than the application's |
229
+ | `use(plugin)` | Installs a plugin; returns a remover |
230
+ | `useRequestMiddleware(mw)` | Wraps every request; returns a remover |
231
+ | `setRequestTimeout(ms)` | Default timeout for this instance's requests |
232
+ | `dispose()` | Unsubscribes, clears registries and the ambient point |
233
+ | `locators` | `.singletons` `.gateways` `.scenarios` `.sharedStores` |
234
+
235
+ Because state lives on the instance and not in module variables, two applications
236
+ can run in one process — an app beside Storybook, a test beside another test —
237
+ without sharing a bus or a cache.
238
+
239
+ ### Bootstrap services
240
+
241
+ ```ts
242
+ await lanka.bootstrap({
243
+ services: [
244
+ { name: "config", init: loadConfig, sync: true, priority: 10 },
245
+ { name: "analytics", init: startAnalytics, optional: true, timeoutMs: 2000 },
246
+ ],
247
+ });
248
+ ```
249
+
250
+ - `sync: true` runs the service in the ordered phase; the default phase is
251
+ parallel.
252
+ - `priority` orders the sync phase, higher first.
253
+ - `optional: true` means a failure does not abort bootstrap. Without it, one
254
+ failed service takes the whole phase with it — and that is usually what you
255
+ want, because an app that starts on a half-executed plan does not know it.
256
+ - `timeoutMs` is a deadline. A service that never settles would otherwise hold
257
+ the first paint forever.
258
+
259
+ ## The host contract
260
+
261
+ ```ts
262
+ interface ILankaHost {
263
+ apiBaseUrl: string;
264
+ httpErrorMessage(status: number): string;
265
+ networkErrorMessage(): string;
266
+ timeoutErrorMessage(): string;
267
+ }
268
+ ```
269
+
270
+ All four fields are required on purpose. Each is a product decision the framework
271
+ cannot make for you — the copy is yours, the base URL is your build's — and a
272
+ missing one is a compile error in the single place a host is passed, rather than
273
+ an `undefined` inside a request URL.
274
+
275
+ Read it anywhere with `getLankaHost()`.
276
+
277
+ ## Flags
278
+
279
+ ```ts
280
+ import { getLankaFlags } from "lanka/config";
281
+
282
+ if (getLankaFlags().isDevelopment) {
283
+ // …
284
+ }
285
+ ```
286
+
287
+ | Flag | Effect |
288
+ | ------------------------------------------------------------------------------------- | --------------------------------------------------- |
289
+ | `isMockMode` | Gateway mock handlers answer instead of the network |
290
+ | `isProduction` | Silences all log output, disables mocks |
291
+ | `isDevelopment` | Enables development-only behaviour |
292
+ | `loggerEnabled` | Master switch for the logger |
293
+ | `loggerGateway`, `loggerScenario`, `loggerViewModel`, `loggerView`, `loggerBootstrap` | Per-channel switches |
294
+
295
+ Wire them from your bundler: `flags: { isProduction: import.meta.env.PROD }`.
296
+
297
+ ## Gateways — talking to a server
298
+
299
+ A gateway states the endpoints an application has and nothing else: no state, no
300
+ error handling, no decisions about what a failure means. Those belong to the
301
+ ViewModel that calls it — which is why gateways stay short.
302
+
303
+ **As a class:**
304
+
305
+ ```ts
306
+ import { ALankaGateway, LankaFetchJsonRequest } from "lanka/gateway";
307
+ import type { ILankaTransport } from "lanka/gateway";
308
+
309
+ export class TodoGateway extends ALankaGateway {
310
+ constructor() {
311
+ // No request, no transport: a gateway that says nothing talks JSON over
312
+ // `fetch`. Supply one when this gateway is not ordinary — see below.
313
+ super({ basePath: "/todos" });
314
+ }
315
+
316
+ list(): Promise<ITodo[]> {
317
+ return this.request<ITodo[]>(this.endpoint());
318
+ }
319
+
320
+ byId(id: number): Promise<ITodo> {
321
+ return this.request<ITodo>(this.endpoint(String(id)));
322
+ }
323
+
324
+ search(term: string): Promise<ITodo[]> {
325
+ const query = this.buildQueryParams({ q: term, tags: ["open"] });
326
+ return this.request<ITodo[]>(this.endpoint(`?${query.toString()}`));
327
+ }
328
+ }
329
+ ```
330
+
331
+ **As a factory** — the same object, without writing a class:
332
+
333
+ ```ts
334
+ import { createLankaGateway } from "lanka/gateway";
335
+
336
+ export const createTodoGateway = () =>
337
+ createLankaGateway({
338
+ basePath: "/todos",
339
+ methods: ({ endpoint, request, buildQueryParams }) => ({
340
+ list: () => request<ITodo[]>(endpoint()),
341
+ byId: (id: number) => request<ITodo>(endpoint(String(id))),
342
+ search: (term: string) =>
343
+ request<ITodo[]>(endpoint(`?${buildQueryParams({ q: term }).toString()}`)),
344
+ }),
345
+ });
346
+ ```
347
+
348
+ Both build the _same_ class. Pick the one your team reads more easily; a fix to
349
+ the framework reaches both at once.
350
+
351
+ `endpoint(path)` prefixes `apiBaseUrl` and the gateway's `basePath`, normalising
352
+ slashes. Pass an absolute URL and it is used as given.
353
+
354
+ ### Requests and transports
355
+
356
+ A **request** decides what a response _is_; a **transport** decides how bytes
357
+ travel. Three of each ship with core:
358
+
359
+ | Request | Answers |
360
+ | --------------------------- | ------------------ |
361
+ | `LankaFetchJsonRequest` | parsed JSON |
362
+ | `LankaFetchRequest` | the raw `Response` |
363
+ | `LankaFetchFormDataRequest` | multipart uploads |
364
+
365
+ Each has a factory twin — `createLankaFetchJsonRequest(…)` and so on — and each
366
+ takes a matching transport (`LankaFetchTransport`, …). Supply your own by
367
+ implementing `ILankaTransport`; the gateway is typed against the port, not the
368
+ implementation.
369
+
370
+ ### Request middleware
371
+
372
+ ```ts
373
+ const remove = lanka.useRequestMiddleware(async (ctx, next) => {
374
+ const started = performance.now();
375
+ const result = await next(ctx);
376
+ report(ctx.url, performance.now() - started);
377
+ return result;
378
+ });
379
+ ```
380
+
381
+ Middleware is a **wrapper**, not a set of hooks, because retry cannot be
382
+ expressed with `onRequest` / `onResponse` / `onError`: an error hook can replace
383
+ an error but cannot run the request again. Registered first wraps the rest. This
384
+ is exactly how [`@lankajs/plugin-http`](https://github.com/lankajs/lanka/blob/main/plugins/http/GUIDE.md) installs retry,
385
+ auth refresh, the CSRF header and idempotency keys.
386
+
387
+ ### Cancellation and timeouts
388
+
389
+ `execute` accepts `signal` and `timeoutMs`. The framework merges your signal with
390
+ its own timer into one signal and still tells the two apart: a timed-out request
391
+ raises `timeout` and is shown to the user, a caller-aborted one raises `aborted`
392
+ and stays silent, because the user has already left.
393
+
394
+ ### Counting requests in flight
395
+
396
+ ```ts
397
+ import { lankaHttpInFlight } from "lanka/gateway";
398
+
399
+ lankaHttpInFlight.subscribe((count) => setBusy(count > 0));
400
+ ```
401
+
402
+ ## Failure
403
+
404
+ Every failure that leaves a request is a `LankaError` with a **tagged kind**:
405
+
406
+ | Kind | Means | The interface usually |
407
+ | --------- | -------------------------- | --------------------------- |
408
+ | `network` | never reached the server | offers a retry |
409
+ | `timeout` | reached it, no answer came | offers a retry |
410
+ | `aborted` | the caller cancelled | shows nothing |
411
+ | `http` | the server said no | shows the server's message |
412
+ | `schema` | the body did not match | reports a break, not a typo |
413
+ | `domain` | your own rule refused | shows your message |
414
+
415
+ ```ts
416
+ import { LankaError, createLankaApiError, handleLankaApiError } from "lanka/errors";
417
+
418
+ try {
419
+ await gateway.list();
420
+ } catch (error) {
421
+ if (error instanceof LankaError && error.kind === "aborted") return;
422
+ setError(handleLankaApiError(error));
423
+ }
424
+ ```
425
+
426
+ Refuse locally with the same shape rather than a bare `throw`, so a screen has
427
+ one failure shape to render:
428
+
429
+ ```ts
430
+ if (term.trim().length === 0) {
431
+ return Promise.reject(createLankaApiError(400, ["a search needs a term"]));
432
+ }
433
+ ```
434
+
435
+ ## Validating a response
436
+
437
+ The validator accepts **any** schema implementing
438
+ [Standard Schema](https://standardschema.dev) — zod 4, valibot, arktype. There
439
+ are no adapter classes: a schema describes itself.
440
+
441
+ ```ts
442
+ async listValidated(): Promise<ITodo[]> {
443
+ const body = await this.request<unknown>(this.endpoint());
444
+ return lankaStandardValidator.validate(todoSchema, body, "todos.list");
445
+ }
446
+ ```
447
+
448
+ The third argument is a label. It appears in the `LankaValidationError` and in
449
+ the log, and it is what turns "invalid response" into "which call".
450
+
451
+ Because Standard Schema's `validate` returns the _transformed_ value, mapping a
452
+ legacy wire format is just a second schema — there is no adapter layer, because
453
+ there is nothing for it to do:
454
+
455
+ ```ts
456
+ const domain = lankaStandardValidator.validate(todoApiSchema, wire, "todos.map");
457
+ return lankaStandardValidator.validate(todoSchema, domain, "todos.check");
458
+ ```
459
+
460
+ An async schema is rejected loudly. A synchronous port cannot await one, and
461
+ answering "fine" would let unvalidated data through.
462
+
463
+ ## ViewModels
464
+
465
+ A ViewModel owns a screen's state and the actions that change it. What you get
466
+ back is a React hook: `const { todos, isLoading, load } = useTodosVM();`
467
+
468
+ **As a factory:**
469
+
470
+ ```ts
471
+ import { createLankaVM } from "lanka/viewmodel";
472
+
473
+ export const createTodosVM = (todoGateway: TodoGateway) =>
474
+ createLankaVM<ITodosState, ITodoActions, { todoGateway: TodoGateway }>({
475
+ name: "TodosVM",
476
+ states: { todos: [], error: null, isLoading: false },
477
+ gateways: () => ({ todoGateway }),
478
+
479
+ createActions: ({ set, get, gateways, trigger }) => ({
480
+ load: async () => {
481
+ set({ isLoading: true, error: null });
482
+ try {
483
+ set({ todos: await gateways.todoGateway.list() });
484
+ } finally {
485
+ set({ isLoading: false });
486
+ }
487
+ },
488
+ complete: (id: number) => {
489
+ set({ todos: markDone(get().todos, id) });
490
+ trigger(todoCompleted, { id });
491
+ },
492
+ }),
493
+
494
+ scenarioHandlers: [
495
+ {
496
+ scenario: todoCompleted,
497
+ handler:
498
+ ({ set, get }) =>
499
+ (data?: { id: number }) => {
500
+ if (!data) return;
501
+ set({ todos: markDone(get().todos, data.id) });
502
+ },
503
+ },
504
+ ],
505
+ });
506
+ ```
507
+
508
+ **As a class** — same behaviour, written against `this`:
509
+
510
+ ```ts
511
+ import { ALankaVM } from "lanka/viewmodel";
512
+
513
+ export class TodosVM extends ALankaVM<ITodosState, ITodoActions, ITodoGateways> {
514
+ protected readonly name = "TodosVM";
515
+
516
+ private readonly gateway: TodoGateway;
517
+
518
+ constructor(gateway: TodoGateway) {
519
+ super();
520
+ this.gateway = gateway;
521
+ }
522
+
523
+ protected override states(): ITodosState {
524
+ return { todos: [], error: null, isLoading: false };
525
+ }
526
+
527
+ protected override createGateways(): ITodoGateways {
528
+ return { todoGateway: this.gateway };
529
+ }
530
+
531
+ protected createActions(): ITodoActions {
532
+ return {
533
+ load: async () => {
534
+ this.set({ isLoading: true });
535
+ try {
536
+ this.set({ todos: await this.gateways.todoGateway.list() });
537
+ } finally {
538
+ this.set({ isLoading: false });
539
+ }
540
+ },
541
+ };
542
+ }
543
+ }
544
+
545
+ export const useTodosVM = new TodosVM(gateway).build();
546
+ ```
547
+
548
+ The protected surface is exactly the factory's context — `set`, `get`,
549
+ `gateways`, `services`, `trigger` — and that is enforced, not a convention. The
550
+ overridable hooks are `states`, `createGateways`, `createServices`,
551
+ `scenarioHandlers`, `enhancers`, `onInit`, `onReset` and `createActions`.
552
+
553
+ ### Config reference
554
+
555
+ | Field | Meaning |
556
+ | ---------------------------------- | --------------------------------------------------- |
557
+ | `name` | Shown in logs and devtools. Required |
558
+ | `states` | The initial state. Omit for a stateless ViewModel |
559
+ | `createActions` | Receives the context, returns the actions |
560
+ | `gateways` / `services` | An object or a factory; reachable as `gateways.x` |
561
+ | `scenarioHandlers` | `{ scenario, handler }` pairs, bound at bootstrap |
562
+ | `enhancers` | Store enhancers, zustand style |
563
+ | `onInit` / `onReset` | Lifecycle hooks over the same context |
564
+ | `enableAccessTrackingOptimization` | Default on; see [common mistakes](#common-mistakes) |
565
+
566
+ ### Using one in a component
567
+
568
+ ```tsx
569
+ const TodoScreen = () => {
570
+ const { todos, isLoading, load } = useTodosVM();
571
+ useEffect(() => {
572
+ void load();
573
+ }, [load]);
574
+ return isLoading ? <Spinner /> : <List items={todos} />;
575
+ };
576
+ ```
577
+
578
+ The hook re-renders a component only for the keys it actually **read**. That is
579
+ usually free performance, and it has one blind spot, described at the end of this
580
+ guide.
581
+
582
+ ## Choosing a coordination tool
583
+
584
+ When "A must affect B", one question decides it: **do A and B co-own a thing, or
585
+ does A announce something to independent Bs?**
586
+
587
+ | The link | The tool |
588
+ | ---------------------------------------------------------- | ------------------ |
589
+ | one screen owns the state and nothing else needs it | one ViewModel |
590
+ | A announces a fact; B, C and D each react in their own way | a **scenario** |
591
+ | A and B edit the same in-flight thing before it is saved | a **shared store** |
592
+
593
+ Read it in that order and stop at the first that fits. The commonest mistake is
594
+ reaching past the first two: two ViewModels wired directly, or a shared store for
595
+ what is really an announcement.
596
+
597
+ A scenario carries something that **happened**. A shared store holds something
598
+ being **co-edited** — a two-step form's draft, a selection two panels must agree
599
+ on. If you would describe the link with a verb in the past tense, it is a
600
+ scenario.
601
+
602
+ ## Shared stores
603
+
604
+ When two screens must not disagree — a selection, a draft, a filter — put the
605
+ state in a shared store and give each screen its own ViewModel over it.
606
+
607
+ ```ts
608
+ import { ALankaSharedStore, ALankaSharedStoreVM } from "lanka/viewmodel";
609
+
610
+ export class TodoStore extends ALankaSharedStore<ISelection> {
611
+ constructor() {
612
+ super(() => ({ selectedId: null }));
613
+ }
614
+ }
615
+
616
+ export class BadgeVM extends ALankaSharedStoreVM<ISelection, IBadgeActions, TodoStore> {
617
+ protected readonly name = "BadgeVM";
618
+
619
+ protected createActions(): IBadgeActions {
620
+ return {
621
+ select: (id: number) => {
622
+ this.set({ selectedId: id });
623
+ },
624
+ clear: () => {
625
+ this.set({ selectedId: null });
626
+ },
627
+ };
628
+ }
629
+ }
630
+ ```
631
+
632
+ The factory twins are `createLankaSharedStore` and `createSharedStoreLankaVM`.
633
+
634
+ Each ViewModel owns its **actions**; the **state** lives once, in the store, so
635
+ two readers cannot drift apart. Passing the value down as props works for one hop
636
+ and breaks at the second; duplicating it into both ViewModels produces two
637
+ answers to one question.
638
+
639
+ ## Stateless ViewModels
640
+
641
+ Roughly half the ViewModels an application writes hold nothing — they answer
642
+ questions about data somebody else owns. Give them no store at all, or every
643
+ consumer re-renders on changes to a state that cannot change.
644
+
645
+ ```ts
646
+ import { createStatelessLankaVM } from "lanka/viewmodel";
647
+
648
+ export const createStatsVM = () =>
649
+ createStatelessLankaVM<IStatsActions>({
650
+ name: "StatsVM",
651
+ createActions: () => ({
652
+ countDone: (todos: readonly ITodo[]) => todos.filter((todo) => todo.done).length,
653
+ }),
654
+ });
655
+ ```
656
+
657
+ The class twin is `ALankaStatelessVM`.
658
+
659
+ ## Lazy ViewModels
660
+
661
+ `createLazyLankaVM`, `createLazyStatelessLankaVM` and
662
+ `createLazySharedStoreLankaVM` build on first use rather than at module load. Use
663
+ them for a screen behind a route most sessions never open.
664
+
665
+ ```ts
666
+ const useSettingsVM = createLazyLankaVM({/* … */});
667
+ // nothing is built yet
668
+ useSettingsVM.dispose(); // drops the store and its subscriptions
669
+ ```
670
+
671
+ **Lazy variants are factory-only, deliberately.** Lazy is a _lifetime_, not a
672
+ role — there is nothing extra to subclass.
673
+
674
+ `dispose()` matters here: a lazy ViewModel subscribes to scenarios on first use,
675
+ and without explicit disposal that subscription outlives the screen.
676
+
677
+ ## Scenarios — cross-screen facts
678
+
679
+ A scenario names a **fact**, not an action: `TodoCompleted`, not `CompleteTodo`.
680
+ Whoever changes something triggers it; whoever cares subscribes. Neither screen
681
+ imports the other — that is the whole reason the layer exists.
682
+
683
+ ```ts
684
+ import { ALankaScenario } from "lanka/scenario";
685
+
686
+ export class TodoCompleted extends ALankaScenario<{ id: number }> {
687
+ readonly name = "TodoCompleted";
688
+ readonly eventType = "todo:completed";
689
+ readonly dataTypeName = "ITodoCompleted";
690
+ }
691
+
692
+ export const todoCompleted = new TodoCompleted();
693
+ ```
694
+
695
+ Keep the single instance beside its class. A second instance is a second event
696
+ nobody listens to — silence, not an error.
697
+
698
+ **As a factory**, when the body is only data, which is most of the time:
699
+
700
+ ```ts
701
+ import { createLankaScenario } from "lanka/scenario";
702
+
703
+ export const todoCompleted = createLankaScenario<{ id: number }>({
704
+ name: "TodoCompleted",
705
+ eventType: "todo:completed",
706
+ dataTypeName: "ITodoCompleted",
707
+ });
708
+ ```
709
+
710
+ A ViewModel subscribes through `scenarioHandlers` and fires through
711
+ `trigger(scenario, data)`.
712
+
713
+ > [!NOTE]
714
+ > Binding happens inside `bootstrap()`, which `startLanka` awaits for you. What
715
+ > matters is that a ViewModel carrying `scenarioHandlers` exists **before** that
716
+ > runs — bootstrap binds what exists when it runs.
717
+
718
+ ### The bus directly
719
+
720
+ ```ts
721
+ import { lankaEventBus } from "lanka/scenario";
722
+
723
+ const off = lankaEventBus.subscribe<ITodo>("todo:completed", onCompleted, {
724
+ replay: "last",
725
+ priority: 10,
726
+ });
727
+ ```
728
+
729
+ Things worth knowing:
730
+
731
+ - **Buffering happens on request only.** Depth is the maximum of what the event
732
+ declares (`registerEvent({ replay: N })`) and what subscribers ask for; when
733
+ the last asker leaves, the buffer is cleared. Nothing is buffered "just in
734
+ case", because that means holding personal data in memory with no consumer.
735
+ - **`replay` counts values:** `false | "last" | N`.
736
+ - **`subscribe` returns an unsubscribe** that removes exactly that subscription.
737
+ Removing by callback cannot tell two subscriptions of one function apart.
738
+ - **Delivery iterates a copy** of the subscriber list, so a handler may
739
+ unsubscribe itself mid-delivery. The deliberate consequence: subscribing
740
+ _during_ delivery waits for the next event.
741
+ - **Middleware returns a decision** — `"pass"` or `{ stop: reason }` — never
742
+ `next()`. A middleware that forgot to call `next()` would make the event vanish
743
+ silently, and a mechanism that exists for observability must not be its own
744
+ blind spot. A stop is written to the event log; so is a throw.
745
+
746
+ ## The locator
747
+
748
+ Four registries, one per kind of object, each reachable by name — so a screen
749
+ never learns where an object came from, which is what makes it replaceable in a
750
+ test.
751
+
752
+ ```ts
753
+ lanka.locators.singletons.register("SessionService", SessionService);
754
+ lanka.locators.gateways.registerInstance("TodoGateway", todoGateway);
755
+
756
+ const session = lanka.resolve<SessionService>("sessionService");
757
+ ```
758
+
759
+ Ambient access, for code that cannot hold an instance:
760
+
761
+ ```ts
762
+ import { lankaSingletons, lankaGateways } from "lanka/locator";
763
+
764
+ lankaSingletons.sessionService.signIn("ada");
765
+ void lankaGateways.todoGateway.list();
766
+ ```
767
+
768
+ A name nobody registered is a **named refusal**, not `undefined`.
769
+
770
+ ### Declaring a singleton
771
+
772
+ ```ts
773
+ import { ALankaSingleton, createLankaSingleton } from "lanka/locator";
774
+
775
+ export class SessionService extends ALankaSingleton {
776
+ private who: string | null = null;
777
+
778
+ signIn(name: string): void {
779
+ this.who = name;
780
+ }
781
+ }
782
+
783
+ export const Clock = createLankaSingleton<IClock>(() => {
784
+ let seen = 0;
785
+ return { ticks: () => (seen += 1) };
786
+ });
787
+ ```
788
+
789
+ Extending the marker is not ceremony. Without it, "a singleton" means "any
790
+ exported function with a prototype", and a stray export in a barrel silently
791
+ becomes part of the public `lankaSingletons.*`.
792
+
793
+ ### Typed ambient access
794
+
795
+ `lankaGateways.todoGateway` is typed when your application publishes `.lanka_di/`
796
+ barrels — see [`@lankajs/tool-di`](https://github.com/lankajs/lanka/blob/main/tools/di/GUIDE.md). Without them the
797
+ facades still work; they are just untyped.
798
+
799
+ ## Scopes
800
+
801
+ ```ts
802
+ const scope = lanka.createScope();
803
+ const vm = scope.resolve<ScreenVM>("screenVM");
804
+ scope.dispose();
805
+ ```
806
+
807
+ A scope is a lifetime shorter than the application's: what it created goes away
808
+ with it. It resolves **only its own** objects — a scope that could hand out
809
+ others' would make closing a screen break the app. Resolving from a closed scope
810
+ is refused loudly, because it is almost always a reference that outlived its
811
+ screen.
812
+
813
+ ## Mock mode
814
+
815
+ ```ts
816
+ list(): Promise<ITodo[]> {
817
+ const mock = createLankaMockHandler(
818
+ () => import("../mocks/todoMocks"),
819
+ (module) => module.todoMocks,
820
+ "todos.list",
821
+ 0,
822
+ );
823
+ if (mock) return mock();
824
+
825
+ return this.request<ITodo[]>(this.endpoint());
826
+ }
827
+ ```
828
+
829
+ `createLankaMockHandler` returns `undefined` while mock mode is off — so the
830
+ branch disappears, the dynamic import is never reached, and **a production bundle
831
+ contains no mock data**, because nothing in it can refer to the module.
832
+
833
+ Always pass the name. Deriving it from the stack trace works only until
834
+ minification, and mock mode is enabled in exactly such a bundle.
835
+
836
+ ## The logger
837
+
838
+ ```ts
839
+ import { lankaLogger } from "lanka/logger";
840
+
841
+ lankaLogger.printGatewayLog("GET /todos", payload);
842
+ ```
843
+
844
+ Channels follow the flags: `loggerGateway`, `loggerScenario`, `loggerViewModel`,
845
+ `loggerView` and `loggerBootstrap`, all under `loggerEnabled`, and all silent
846
+ when `isProduction`. Add a sink (`ILankaLoggerSink`) to ship lines somewhere
847
+ other than the console.
848
+
849
+ ## Plugins
850
+
851
+ A plugin is something **core** calls (`app → core → plugin`); a module is
852
+ something **you** call (`app → module`). If core needs no hook for it, it is a
853
+ module — and it should stay one, because every extension point is a promise for
854
+ the lifetime of a major version.
855
+
856
+ ```ts
857
+ import { ALankaPlugin } from "lanka";
858
+
859
+ class TimingPlugin extends ALankaPlugin {
860
+ readonly name = "timing";
861
+
862
+ install(lanka: ILankaInstance) {
863
+ return lanka.useRequestMiddleware(timing); // the returned function uninstalls
864
+ }
865
+ }
866
+
867
+ const remove = lanka.use(new TimingPlugin());
868
+ ```
869
+
870
+ `install` receives the instance so a plugin has no private route into the
871
+ framework, and two instances in one process do not share its configuration.
872
+ Registering the same name twice is refused — two copies of a retry policy would
873
+ silently double the request count.
874
+
875
+ ## Roles — both styles for your own layer
876
+
877
+ Every role in lanka ships as a class _and_ a factory over one implementation. You
878
+ can have the same for a layer of your own — a repository, a presenter, a command:
879
+
880
+ ```ts
881
+ import { defineLankaRole } from "lanka/role";
882
+
883
+ export const createRepository = defineLankaRole(openRepository);
884
+ ```
885
+
886
+ `openRepository` builds the class and hands back `{ instance, context }`, where
887
+ `context` is its protected surface. Write it in the role's own module: `protected`
888
+ is readable only from inside a deriving class body, so a generic helper outside
889
+ the hierarchy cannot reach it.
890
+
891
+ ## Import map
892
+
893
+ | Import | Contains |
894
+ | ------------------ | -------------------------------------------------------------------------- |
895
+ | `lanka` | `createLanka`, `LankaError`, `lankaLogger`, flags, host, `defineLankaRole` |
896
+ | `lanka/bootstrap` | `createLanka`, `ALankaPlugin`, `resetActiveLanka` |
897
+ | `lanka/config` | `getLankaHost`, `getLankaFlags` |
898
+ | `lanka/errors` | `LankaError`, `createLankaApiError`, `handleLankaApiError` |
899
+ | `lanka/gateway` | gateways, requests, transports, query params, the in-flight counter |
900
+ | `lanka/locator` | singletons, and the four ambient facades |
901
+ | `lanka/logger` | `lankaLogger`, `LankaLogger`, sinks |
902
+ | `lanka/mock` | `createLankaMockHandler` |
903
+ | `lanka/role` | `defineLankaRole` |
904
+ | `lanka/scenario` | scenarios, the event bus, scenario bootstrap |
905
+ | `lanka/validation` | `lankaStandardValidator`, `LankaValidationError` |
906
+ | `lanka/viewmodel` | every ViewModel shape, and shared stores |
907
+ | `lanka/extend` | mechanism for tooling and alternative implementations. Changes in a minor |
908
+ | `lanka/internal` | primitives shared between lanka packages. Changes in any release |
909
+
910
+ Names under the `lanka/*` facades are kept until a major version and are never
911
+ removed. Reaching past the facade is possible, deliberate and visible in review —
912
+ which beats making it impossible and having people fork the framework.
913
+
914
+ ## What else ships
915
+
916
+ Core is enough to build an application. Four packages exist to make working on
917
+ one easier, and each is optional.
918
+
919
+ | Package | What it does for you |
920
+ | -------------------------------------------------- | ----------------------------------------------------------------------------- |
921
+ | [`@lankajs/tool-di`](https://github.com/lankajs/lanka/blob/main/tools/di/GUIDE.md) | The `@lanka_di` alias and the barrels. Effectively required in a vite app |
922
+ | [`@lankajs/tool-eslint`](https://github.com/lankajs/lanka/blob/main/tools/eslint/GUIDE.md) | The boundaries above, as lint rules that name the file and the line |
923
+ | [`@lankajs/tool-testing`](https://github.com/lankajs/lanka/blob/main/tools/testing/GUIDE.md) | A fresh framework per test, a render helper, two doubles, the bench yardstick |
924
+ | [`@lankajs/tool-skills`](https://github.com/lankajs/lanka/blob/main/tools/skills/GUIDE.md) | The agent skills of the packages you installed, in your project |
925
+
926
+ ### Skills for your coding agent
927
+
928
+ Every package ships a skill: what it is for, the shapes to write, and the
929
+ refusals — the things that look like a missing feature and are the feature. Two
930
+ ways to install one:
931
+
932
+ ```bash
933
+ # from the framework's repository, in Claude Code
934
+ /plugin marketplace add lankajs/lanka
935
+ /plugin install lanka-core@lankajs
936
+
937
+ # or from the packages you already installed — the skill for THAT version
938
+ npx lanka-skills sync
939
+ ```
940
+
941
+ > [!TIP]
942
+ > Prefer the second where it works. A skill installed from git describes the main
943
+ > branch; one installed from your `node_modules` describes the code you are
944
+ > actually running. Start with `lanka-packages`, which routes to the rest by
945
+ > problem.
946
+
947
+ ### The modules and plugins
948
+
949
+ Nine more packages solve problems you may or may not have — realtime, retry
950
+ policy, optimistic updates, list handling, storage, prefetching. The table of
951
+ "add it when" is in [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md#adopting-the-packages),
952
+ and each has a guide of its own.
953
+
954
+ ## Cases
955
+
956
+ ### A screen that loads a list
957
+
958
+ 1. **Gateway** — one method per endpoint, validating the body.
959
+ 2. **ViewModel** — `states` for the list, the flag and the error; an action that
960
+ sets the flag, calls the gateway, and clears the flag in `finally`.
961
+ 3. **Component** — reads the hook, calls the action in an effect, renders three
962
+ states: loading, error, content.
963
+
964
+ Nothing else. If you find yourself writing a `useState` for the rows beside the
965
+ ViewModel, the ViewModel is the one that should hold them.
966
+
967
+ ### One screen changes something another must see
968
+
969
+ The change happens in the owner's ViewModel, and it announces a fact:
970
+
971
+ ```ts
972
+ complete: async (id: number) => {
973
+ await gateways.todoGateway.complete(id);
974
+ set({ todos: markDone(get().todos, id) });
975
+ trigger(todoCompleted, { id }); // ← the announcement
976
+ };
977
+ ```
978
+
979
+ Every other ViewModel that cares subscribes through `scenarioHandlers` and
980
+ decides for itself what the fact means for its own state. Neither imports the
981
+ other.
982
+
983
+ **Trigger with the data when you have it.** If the server returned the updated
984
+ object, put it in the payload — subscribers apply it instead of each refetching.
985
+ If the call returned nothing, trigger the bare fact and let each subscriber
986
+ decide whether it needs a read.
987
+
988
+ ### A two-step form, and the world afterwards
989
+
990
+ The case people get wrong, because it needs **three different tools at once**:
991
+
992
+ | The link | The tool |
993
+ | ---------------------------------------------------- | ------------------ |
994
+ | step 1 and step 2 edit one unsaved draft | a **shared store** |
995
+ | "order placed" must clear a badge in the header | a **scenario** |
996
+ | "order placed" must refresh a list on another screen | the same scenario |
997
+
998
+ ```
999
+ AddressStep ─┐
1000
+ ├─► CheckoutStore (shared) ← one draft, two editors
1001
+ PaymentStep ─┘
1002
+ │ user presses "Place order"
1003
+
1004
+ PaymentStepVM.placeOrder()
1005
+ ├─► orderGateway.place(draft) ← the only network call
1006
+ ├─► trigger(orderPlaced, { order }) ← ONE announcement…
1007
+ │ ├─► CartBadgeVM (subscribed) → clears the badge
1008
+ │ └─► OrdersListVM (subscribed) → applies the order
1009
+ ├─► clear the store (the draft is spent)
1010
+ └─► navigate to the confirmation
1011
+ ```
1012
+
1013
+ Three things worth saying out loud:
1014
+
1015
+ - **A shared store between the steps, not a scenario.** They co-own one buffer;
1016
+ a scenario is for "this happened", not "we are editing the same thing".
1017
+ - **A scenario for the badge, not a shared store.** Those are independent owners.
1018
+ - **The orchestration lives in the ViewModel**, not in the button. The component
1019
+ calls `placeOrder()` and stays dumb.
1020
+
1021
+ And one that only bites later: if the confirmation screen's ViewModel is
1022
+ **lazy**, it is not subscribed yet, so the scenario will not reach it. Write what
1023
+ it needs before navigating, or pass an id in the route.
1024
+
1025
+ ### A screen behind a route most sessions never open
1026
+
1027
+ `createLazyLankaVM`, and `dispose()` when the screen goes. Otherwise its scenario
1028
+ subscriptions outlive it, and it keeps reacting to facts about a screen nobody is
1029
+ looking at.
1030
+
1031
+ ### A service several screens need
1032
+
1033
+ Not an import — a name. Register it once at start-up
1034
+ (`lanka.locators.singletons.register(...)`), reach it as
1035
+ `lankaSingletons.sessionService`, and a test replaces the object behind the name
1036
+ without touching a screen.
1037
+
1038
+ ## Common mistakes
1039
+
1040
+ **Building a ViewModel before the framework exists.** It resolves against no
1041
+ runtime. `startLanka` — or `createLanka` — comes first.
1042
+
1043
+ **A scenario handler that never fires.** Either `bootstrap()` was never awaited,
1044
+ or the ViewModel was constructed after it: bootstrap binds what exists when it
1045
+ runs.
1046
+
1047
+ **Reading state through a getter and wondering why the screen froze.** A consumer
1048
+ re-renders only for the keys it _read through the proxy_. If a component's only
1049
+ link to `todos` goes through a getter that calls `get()` internally, that read
1050
+ happens past the proxy and no re-render follows. There is no error.
1051
+
1052
+ In development the framework notices and warns, naming the ViewModel and the key.
1053
+ The fix is `enableAccessTrackingOptimization: false` on that ViewModel. Do **not**
1054
+ "fix" it by destructuring for the side effect: that reads as dead code, and the
1055
+ next refactor or lint autofix deletes it.
1056
+
1057
+ **Naming a scenario after an action.** `CompleteTodo` invites the subscriber to
1058
+ _do_ the completing; `TodoCompleted` states what happened. The second is what
1059
+ lets three screens react differently without knowing about each other.
1060
+
1061
+ **Letting a lazy ViewModel leak.** Call `dispose()` when its screen goes away.
1062
+
1063
+ **Validating in the screen instead of the gateway.** A gateway is where a body
1064
+ stops being `unknown`. Validate anywhere else and the same three guards end up
1065
+ spread over every consumer, each slightly differently wrong.
1066
+
1067
+ ## Recap
1068
+
1069
+ - **`startLanka({ apiBaseUrl })`** is the whole start-up: create, activate,
1070
+ install plugins, bootstrap. Write the two calls out only when something must
1071
+ happen between them.
1072
+ - **Imports go one way.** A ViewModel reaches a gateway; a gateway does not know
1073
+ ViewModels exist.
1074
+ - **A gateway states endpoints** and holds no state — it is also where a body
1075
+ stops being `unknown`.
1076
+ - **A ViewModel owns a screen's state**, and comes in three shapes: stateful,
1077
+ stateless, and over a shared store. Each ships as a class and a factory over
1078
+ one implementation.
1079
+ - **For "A must affect B"**, ask whether they co-own a thing (a shared store) or
1080
+ A announces something (a scenario). Never an import between ViewModels.
1081
+ - **`LankaError` carries a kind**, because `aborted` and `network` need different
1082
+ things from the interface.
1083
+ - **What fails loudly does so on purpose**: a name nobody registered, an async
1084
+ schema, a mock in production. Those are refusals, not gaps.
1085
+
1086
+ ---
1087
+
1088
+ Recommended architecture: [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md) · Testing
1089
+ helpers: [`@lankajs/tool-testing`](https://github.com/lankajs/lanka/blob/main/tools/testing/GUIDE.md) · Maintaining
1090
+ this package: [SKILL.md](https://github.com/lankajs/lanka/blob/main/core/SKILL.md) · Repository map: [../README.md](https://github.com/lankajs/lanka/blob/main/README.md)