@k8ordo/state 0.1.0 → 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.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # @k8ordo/state
2
+
3
+ Declare state by where it lives — URL search params, hidden history-entry
4
+ state, localStorage, sessionStorage, a cookie, or memory. One zod schema per
5
+ boundary place (URL, entry, Web Storage, cookie) derives the server-side
6
+ read, canonical links, salvage of stale data, and a client subscription with exact per-key
7
+ change detection; memory never crosses a boundary, so it is a typed shared
8
+ box with no schema. Links and GET forms write the URL before JavaScript loads
9
+ under any router (the server renders what they wrote wherever it reads the
10
+ search); imperative URL updates ride the Navigation API.
11
+
12
+ Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
13
+ Components, uses only what has reached Baseline newly available, and ships no
14
+ polyfills or legacy fallbacks.
15
+
16
+ - **Documentation**: https://ordo.k8o.me/state
17
+ - **Design guide**: [docs/GUIDE.md](docs/GUIDE.md) — shipped inside this package
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @k8ordo/state zod
23
+ # or
24
+ pnpm add @k8ordo/state zod
25
+ ```
26
+
27
+ ## Peer Dependencies
28
+
29
+ <!-- peers -->
30
+
31
+ | Package | Version | Required | Needed for |
32
+ | ---------------- | ------- | -------- | ------------------------------------------------------- |
33
+ | `react` | ≥19.3.0 | yes | `useAppState` |
34
+ | `zod` | ^4.4.3 | yes | the schemas (`zod/mini` works, and is the lighter pick) |
35
+ | `@k8ordo/router` | ^1.0.0 | optional | typed `href` paths from the app's route table |
36
+ | `typescript` | ≥7.0.0 | optional | the shipped type declarations |
37
+ | `@types/react` | ≥19.3.0 | optional | the shipped type declarations |
38
+
39
+ <!-- /peers -->
40
+
41
+ The schema ships to the browser here — the client parses and serializes with
42
+ it — so reach for `zod/mini` unless the app already pays for classic `zod`.
43
+ `@k8ordo/router` is a type-only peer: it is never loaded at runtime.
44
+
45
+ ## Quick Start
46
+
47
+ One definition per place, in a shared module with no `'use client'`:
48
+
49
+ ```ts
50
+ // state.ts
51
+ import { defineLocalState, definePageState } from '@k8ordo/state';
52
+ import * as z from 'zod/mini';
53
+
54
+ export const listState = definePageState('list', {
55
+ url: z.object({
56
+ q: z._default(z.string(), ''),
57
+ page: z._default(z.coerce.number(), 1),
58
+ }),
59
+ });
60
+
61
+ export const prefs = defineLocalState(
62
+ 'prefs',
63
+ z.object({ view: z._default(z.enum(['grid', 'table']), 'grid') }),
64
+ );
65
+ ```
66
+
67
+ ```tsx
68
+ // page.tsx — a Next.js Server Component
69
+ import { listState } from './state';
70
+
71
+ export default async function Page({ searchParams }: PageProps<'/products'>) {
72
+ const url = listState.parseUrl(await searchParams); // typed, defaults applied
73
+ const products = await fetchProducts(url);
74
+ return (
75
+ <>
76
+ <Filters initialUrl={url} />
77
+ <ProductList products={products} />
78
+ <a href={listState.href('/products', { ...url, page: url.page + 1 })}>
79
+ next
80
+ </a>
81
+ </>
82
+ );
83
+ }
84
+ ```
85
+
86
+ ```tsx
87
+ // filters.tsx
88
+ 'use client';
89
+ import { useAppState } from '@k8ordo/state';
90
+
91
+ import { listState, prefs } from './state';
92
+
93
+ export function Filters({ initialUrl }: FiltersProps) {
94
+ const [{ q, page }, update] = useAppState(listState, { initialUrl });
95
+ const [{ view }, updatePrefs] = useAppState(prefs, ['view']);
96
+
97
+ return (
98
+ <>
99
+ <p>{q === '' ? 'all products' : `searching “${q}”`}</p>
100
+ <button type="button" onClick={() => update({ page: page + 1 })}>
101
+ next page
102
+ </button>
103
+ <button type="button" onClick={() => updatePrefs({ view: 'table' })}>
104
+ table view
105
+ </button>
106
+ </>
107
+ );
108
+ }
109
+ ```
110
+
111
+ `parseUrl` needs a router that hands the page its search, as Next.js does.
112
+ In `@k8ordo/framework`'s server mode a page asks for it by exporting the url
113
+ schema —
114
+ `export const search = listState.url` — and receives the slot already parsed
115
+ as its `search` prop, ready to pass as `initialUrl`. Static mode refuses that
116
+ export at build time, since a file is the same whatever the search
117
+ holds; there, and on any page that does not export `search`, `useAppState`
118
+ reads the url slot in the browser. A preference the server should render goes
119
+ in `defineCookieState` instead of `defineLocalState`: in server mode,
120
+ `parseCookies(request.cookies)` reads it and `initialCookie` seeds the first
121
+ render, so the default never flashes.
122
+
123
+ `update()` applies synchronously, batches per handler into one write (memory
124
+ applies per call), and returns `{ committed, finished }` handles. `href` omits
125
+ every field at its default, so every link is canonical. A value the schema
126
+ rejects — a URL a user edited, a row an older schema wrote — falls back to its
127
+ own default, field by field.
128
+
129
+ The [design guide](docs/GUIDE.md) covers the rest: the places and when each
130
+ fits, history-entry state, cookie state the server renders (and why it is
131
+ never a secret), migrating stored rows with `version` and `migrate`,
132
+ batching and update handles, subscription granularity,
133
+ router requirements, typed routes via `Register`, reading local state before
134
+ hydration with `inlineRead()`, and GET forms with `@k8ordo/form`.
135
+
136
+ ## AI Agent Documentation
137
+
138
+ The docs ship **inside the package**, so an agent always reads the exact
139
+ version you installed — there is no snapshot to copy or re-sync on upgrade.
140
+
141
+ Point your agent at them once by pasting this into your project's `CLAUDE.md` /
142
+ `AGENTS.md`:
143
+
144
+ ```markdown
145
+ Use `@k8ordo/state` for URL, history-entry, localStorage, sessionStorage,
146
+ cookie and shared memory state. Before adding or changing state, read
147
+ `node_modules/@k8ordo/state/docs/GUIDE.md`. Declare each state by where it
148
+ lives (`definePageState` / `defineLocalState` / `defineSessionState` /
149
+ `defineCookieState` / `defineMemoryState`) in a shared module, read the url slot with `parseUrl`
150
+ where the router hands the page its search and a cookie state with
151
+ `parseCookies` where it hands the page the request's cookies, build links
152
+ with `href`, and subscribe on the client with `useAppState`. Every boundary
153
+ field needs a default or `.optional()`; never put a secret in a cookie state
154
+ (the browser writes it, so it cannot be `HttpOnly`); never mirror a
155
+ definition's values into React state.
156
+ ```
157
+
158
+ What each surface gives an agent:
159
+
160
+ | Surface | Where |
161
+ | -------------------------- | ---------------------------------------------- |
162
+ | Design guide (entry point) | `node_modules/@k8ordo/state/docs/GUIDE.md` |
163
+ | Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
164
+ | Markdown twin on the web | https://ordo.k8o.me/state/docs/GUIDE.md |
165
+
166
+ ## License
167
+
168
+ MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
@@ -0,0 +1,9 @@
1
+ //#region src/base.d.ts
2
+ /**
3
+ * A path as the URL spells it: Vite's `base` in front, so `/products` is
4
+ * `/docs/products` under `base: '/docs/'` and `/` is `/docs/`. The same rule
5
+ * as `@k8ordo/router`'s `withBase` — not borrowed, because the router is an
6
+ * optional peer this package only reads types from.
7
+ */
8
+ export declare const withBase: (path: string) => string;
9
+ //#endregion
package/dist/base.mjs ADDED
@@ -0,0 +1,15 @@
1
+ //#region src/base.ts
2
+ /**
3
+ * A path as the URL spells it: Vite's `base` in front, so `/products` is
4
+ * `/docs/products` under `base: '/docs/'` and `/` is `/docs/`. The same rule
5
+ * as `@k8ordo/router`'s `withBase` — not borrowed, because the router is an
6
+ * optional peer this package only reads types from.
7
+ */
8
+ const withBase = (path) => {
9
+ const base = import.meta.env === void 0 ? "/" : import.meta.env.BASE_URL;
10
+ if (!base.startsWith("/") || base === "/") return path;
11
+ const prefix = base.endsWith("/") ? base.slice(0, -1) : base;
12
+ return path === "/" ? `${prefix}/` : `${prefix}${path}`;
13
+ };
14
+ //#endregion
15
+ export { withBase };
@@ -0,0 +1,38 @@
1
+ import { StateSchema } from "./schema/object.mjs";
2
+ import { RowCodec, Versioning } from "./row/codec.mjs";
3
+ import { output } from "zod/v4/core";
4
+ //#region src/cookie-state.d.ts
5
+ /**
6
+ * App-scope state that lives in a cookie: device-persistent and shared across
7
+ * tabs like local state, and — unlike it — sent with every request, so a
8
+ * server that reads the request's cookies renders the real values instead of
9
+ * the defaults. The browser writes it, so it can never be `HttpOnly`: a
10
+ * preference, never a secret.
11
+ */
12
+ export type CookieState<Schema extends StateSchema = StateSchema> = {
13
+ kind: 'cookie';
14
+ /** Identity of this state: the cookie name and the store registry slot. */
15
+ key: string;
16
+ /** The schema as passed. */
17
+ schema: Schema;
18
+ /** The cookie the values are written under: `k8ordo-state.<key>`. */
19
+ cookieName: string;
20
+ /**
21
+ * Reads the values out of a request's cookies on the server —
22
+ * `request.cookies` under `@k8ordo/framework`'s server mode, whose values arrive
23
+ * percent-decoded. Absent, corrupt, or rejected fields get their defaults.
24
+ */
25
+ parseCookies: (cookies: ReadonlyMap<string, string>) => output<Schema>;
26
+ /**
27
+ * The value to hand a server's cookie API when it writes the same cookie
28
+ * (`cookies().set` from `@k8ordo/framework/server`, Next.js's): the JSON of these
29
+ * values, left for that API to percent-encode, as the browser store
30
+ * encodes it before writing. Unspecified fields mean their default, and
31
+ * the values pass the schema first, as `update()` does.
32
+ */
33
+ cookieValue: (values?: Readonly<Partial<output<Schema>>>) => string;
34
+ };
35
+ export declare const parseCookieText: (text: string | undefined) => unknown;
36
+ export declare const cookieCodecOf: (def: CookieState) => RowCodec;
37
+ export declare const defineCookieState: <Schema extends StateSchema>(key: string, schema: Schema, versioning?: Versioning<Schema>) => CookieState<Schema>;
38
+ //#endregion
@@ -0,0 +1,35 @@
1
+ import { createRowCodec } from "./row/codec.mjs";
2
+ //#region src/cookie-state.ts
3
+ const COOKIE_NAME_PREFIX = "k8ordo-state.";
4
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
5
+ const parseCookieText = (text) => {
6
+ if (text === void 0) return void 0;
7
+ try {
8
+ return JSON.parse(text);
9
+ } catch {
10
+ return;
11
+ }
12
+ };
13
+ const codecs = /* @__PURE__ */ new WeakMap();
14
+ const cookieCodecOf = (def) => {
15
+ const codec = codecs.get(def);
16
+ if (codec === void 0) throw new TypeError(`"${def.key}" was not created by defineCookieState of this module instance`);
17
+ return codec;
18
+ };
19
+ const defineCookieState = (key, schema, versioning) => {
20
+ const cookieName = `${COOKIE_NAME_PREFIX}${key}`;
21
+ if (!TOKEN.test(cookieName)) throw new TypeError(`"${key}" cannot name a cookie — use letters, digits and !#$%&'*+-.^_\`|~`);
22
+ const codec = createRowCodec(schema, "cookie", key, versioning);
23
+ const def = {
24
+ kind: "cookie",
25
+ key,
26
+ schema,
27
+ cookieName,
28
+ parseCookies: (cookies) => codec.read(parseCookieText(cookies.get(cookieName))).values,
29
+ cookieValue: (values) => JSON.stringify(codec.row(codec.salvage(values ?? {})))
30
+ };
31
+ codecs.set(def, codec);
32
+ return def;
33
+ };
34
+ //#endregion
35
+ export { cookieCodecOf, defineCookieState, parseCookieText };
@@ -1,6 +1,6 @@
1
1
  import { StateSchema, StateValues } from "../schema/object.mjs";
2
2
  //#region src/entry/codec.d.ts
3
- type StoredCodec = {
3
+ export type StoredCodec = {
4
4
  keys: readonly string[];
5
5
  defaults: Readonly<StateValues>;
6
6
  parse: (stored: unknown) => StateValues;
@@ -13,6 +13,5 @@ type StoredCodec = {
13
13
  * that what comes back may have been written by an older schema, or by
14
14
  * nobody.
15
15
  */
16
- declare const createStoredCodec: (schema: StateSchema, slot: string) => StoredCodec;
17
- //#endregion
18
- export { StoredCodec, createStoredCodec };
16
+ export declare const createStoredCodec: (schema: StateSchema, slot: string) => StoredCodec;
17
+ //#endregion
package/dist/index.d.mts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { StateSchema } from "./schema/object.mjs";
2
+ import { Versioning } from "./row/codec.mjs";
3
+ import { CookieState, defineCookieState } from "./cookie-state.mjs";
2
4
  import { Register, RegisteredPath } from "./register.mjs";
3
5
  import { UrlInput } from "./url/codec.mjs";
4
- import { OutputOf, PageState, definePageState } from "./page-state.mjs";
5
- import { LocalState, defineLocalState } from "./local-state.mjs";
6
+ import { OutputOf, PageState, definePageState, urlReader } from "./page-state.mjs";
7
+ import { LocalState, SessionState, defineLocalState, defineSessionState } from "./storage-state.mjs";
6
8
  import { MemoryState, defineMemoryState } from "./memory-state.mjs";
7
9
  import { UpdateHandle } from "./store/core.mjs";
8
10
  import { UpdateOptions } from "./store/page-store.mjs";
9
11
  import { AnyState, useAppState } from "./use-app-state.mjs";
10
12
  import { resetStateRegistry } from "./store/registry.mjs";
11
- export { type AnyState, type LocalState, type MemoryState, type OutputOf, type PageState, type Register, type RegisteredPath, type StateSchema, type UpdateHandle, type UpdateOptions, type UrlInput, defineLocalState, defineMemoryState, definePageState, resetStateRegistry, useAppState };
13
+ export { type AnyState, type CookieState, type LocalState, type MemoryState, type OutputOf, type PageState, type Register, type RegisteredPath, type SessionState, type StateSchema, type UpdateHandle, type UpdateOptions, type UrlInput, type Versioning, defineCookieState, defineLocalState, defineMemoryState, definePageState, defineSessionState, resetStateRegistry, urlReader, useAppState };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
- import { definePageState } from "./page-state.mjs";
2
- import { defineLocalState } from "./local-state.mjs";
1
+ import { defineCookieState } from "./cookie-state.mjs";
2
+ import { definePageState, urlReader } from "./page-state.mjs";
3
+ import { defineLocalState, defineSessionState } from "./storage-state.mjs";
3
4
  import { defineMemoryState } from "./memory-state.mjs";
4
5
  import { resetStateRegistry } from "./store/registry.mjs";
5
6
  import { useAppState } from "./use-app-state.mjs";
6
- export { defineLocalState, defineMemoryState, definePageState, resetStateRegistry, useAppState };
7
+ export { defineCookieState, defineLocalState, defineMemoryState, definePageState, defineSessionState, resetStateRegistry, urlReader, useAppState };
@@ -5,13 +5,12 @@ import { StateValues } from "./schema/object.mjs";
5
5
  * The one kind with no schema: its values never leave the runtime, so there
6
6
  * is no boundary to re-validate at — a typed `update` is the only writer.
7
7
  */
8
- type MemoryState<Values extends StateValues = StateValues> = {
8
+ export type MemoryState<Values extends StateValues = StateValues> = {
9
9
  kind: 'memory';
10
10
  /** Identity of this state: the store registry slot. */
11
11
  key: string;
12
12
  /** The initial values; also what SSR renders. */
13
13
  initial: Readonly<Values>;
14
14
  };
15
- declare const defineMemoryState: <Values extends StateValues>(key: string, initial: Values) => MemoryState<Values>;
16
- //#endregion
17
- export { MemoryState, defineMemoryState };
15
+ export declare const defineMemoryState: <Values extends StateValues>(key: string, initial: Values) => MemoryState<Values>;
16
+ //#endregion
@@ -4,8 +4,8 @@ import { RegisteredPath } from "./register.mjs";
4
4
  import { UrlCodec, UrlInput } from "./url/codec.mjs";
5
5
  import { output } from "zod/v4/core";
6
6
  //#region src/page-state.d.ts
7
- type OutputOf<Schema> = Schema extends StateSchema ? output<Schema> : Record<never, never>;
8
- type PageState<Url extends StateSchema | undefined = StateSchema | undefined, Entry extends StateSchema | undefined = StateSchema | undefined> = {
7
+ export type OutputOf<Schema> = Schema extends StateSchema ? output<Schema> : Record<never, never>;
8
+ export type PageState<Url extends StateSchema | undefined = StateSchema | undefined, Entry extends StateSchema | undefined = StateSchema | undefined> = {
9
9
  kind: 'page';
10
10
  /** Identity of this state: store registry slot, entry-state namespace. */
11
11
  key: string;
@@ -17,10 +17,11 @@ type PageState<Url extends StateSchema | undefined = StateSchema | undefined, En
17
17
  parseUrl: (input: UrlInput) => OutputOf<Url>;
18
18
  /**
19
19
  * Builds a link. Unspecified fields mean their default and defaults are
20
- * omitted from the query, so canonical URLs stay short. The path literal
21
- * survives in the type, which is what lets typed-route checks pass.
20
+ * omitted from the query, so canonical URLs stay short. The path is in the
21
+ * table's terms and the URL carries Vite's `base` in front of it; the path
22
+ * literal survives in the type, which is what lets typed-route checks pass.
22
23
  */
23
- href: <Path extends RegisteredPath>(base: Path, values?: Readonly<Partial<OutputOf<Url>>>) => Path | `${Path}?${string}`;
24
+ href: <Path extends string>(path: RegisteredPath<Path>, values?: Readonly<Partial<OutputOf<Url>>>) => Path | `${Path}?${string}`;
24
25
  /** The query string alone (no `?`), for handrolled URL composition. */
25
26
  search: (values?: Readonly<Partial<OutputOf<Url>>>) => string;
26
27
  };
@@ -32,22 +33,30 @@ type SharedKeys<Url, Entry> = Extract<keyof OutputOf<Url>, keyof OutputOf<Entry>
32
33
  type DisjointGuard<Url, Entry> = SharedKeys<Url, Entry> extends never ? unknown : {
33
34
  entry: `field declared in both url and entry: ${SharedKeys<Url, Entry>}`;
34
35
  };
35
- type PageInternals = {
36
+ export type PageInternals = {
36
37
  url: UrlCodec | null;
37
38
  entry: StoredCodec | null;
38
39
  /** url keys first, then entry keys — the flat snapshot's full key set. */
39
40
  keys: readonly string[];
40
41
  };
41
- declare const internalsOf: (def: PageState) => PageInternals;
42
- declare function definePageState<Url extends StateSchema>(key: string, config: {
42
+ export declare const internalsOf: (def: PageState) => PageInternals;
43
+ export declare function definePageState<Url extends StateSchema>(key: string, config: {
43
44
  url: Url;
44
45
  }): PageState<Url, undefined>;
45
- declare function definePageState<Entry extends StateSchema>(key: string, config: {
46
+ export declare function definePageState<Entry extends StateSchema>(key: string, config: {
46
47
  entry: Entry;
47
48
  }): PageState<undefined, Entry>;
48
- declare function definePageState<Url extends StateSchema, Entry extends StateSchema>(key: string, config: {
49
+ export declare function definePageState<Url extends StateSchema, Entry extends StateSchema>(key: string, config: {
49
50
  url: Url;
50
51
  entry: Entry;
51
52
  } & DisjointGuard<Url, Entry>): PageState<Url, Entry>;
52
- //#endregion
53
- export { OutputOf, PageInternals, PageState, definePageState, internalsOf };
53
+ /**
54
+ * Reads the url slot a schema describes out of a URL's search, the way
55
+ * `parseUrl` reads it for the definition the schema came from — defaults
56
+ * applied, repeated params gathered, a rejected field salvaged to its
57
+ * default. For code handed the schema alone: `@k8ordo/framework` reads the
58
+ * search of a page that exports `search = listState.url` through it. The
59
+ * codec is built once, when this is called.
60
+ */
61
+ export declare const urlReader: <Url extends StateSchema>(schema: Url) => ((input: UrlInput) => output<Url>);
62
+ //#endregion
@@ -1,3 +1,4 @@
1
+ import { withBase } from "./base.mjs";
1
2
  import { createStoredCodec } from "./entry/codec.mjs";
2
3
  import { createUrlCodec } from "./url/codec.mjs";
3
4
  //#region src/page-state.ts
@@ -21,9 +22,10 @@ function definePageState(key, config) {
21
22
  entry: config.entry,
22
23
  parseUrl: (input) => url === null ? {} : url.parse(input),
23
24
  search,
24
- href: (base, values) => {
25
+ href: (path, values) => {
25
26
  const query = search(values);
26
- return query === "" ? base : `${base}?${query}`;
27
+ const link = withBase(path);
28
+ return query === "" ? link : `${link}?${query}`;
27
29
  }
28
30
  };
29
31
  internals.set(def, {
@@ -33,5 +35,17 @@ function definePageState(key, config) {
33
35
  });
34
36
  return def;
35
37
  }
38
+ /**
39
+ * Reads the url slot a schema describes out of a URL's search, the way
40
+ * `parseUrl` reads it for the definition the schema came from — defaults
41
+ * applied, repeated params gathered, a rejected field salvaged to its
42
+ * default. For code handed the schema alone: `@k8ordo/framework` reads the
43
+ * search of a page that exports `search = listState.url` through it. The
44
+ * codec is built once, when this is called.
45
+ */
46
+ const urlReader = (schema) => {
47
+ const codec = createUrlCodec(schema);
48
+ return (input) => codec.parse(input);
49
+ };
36
50
  //#endregion
37
- export { definePageState, internalsOf };
51
+ export { definePageState, internalsOf, urlReader };
@@ -1,22 +1,41 @@
1
+ import { NavigablePath, NavigablePatternOf, Routes, RoutesRecord } from "@k8ordo/router";
1
2
  //#region src/register.d.ts
2
3
  /**
3
- * The app-side hook for typed route paths. An application augments this once:
4
+ * The app-side hook for typed route paths. An application augments this once,
5
+ * with the same line its `@k8ordo/router` augmentation uses:
4
6
  *
5
7
  * ```ts
6
- * import type { Route } from 'next';
8
+ * import type { routes } from './routes';
7
9
  * declare module '@k8ordo/state' {
8
- * interface Register { path: Route }
10
+ * interface Register { routes: typeof routes }
9
11
  * }
10
12
  * ```
11
13
  *
12
- * and every `href()` in the app is constrained to its router's route type.
13
- * Declared as an interface — the one exception to the repository's type-only
14
- * rule — because declaration merging is the entire mechanism.
14
+ * and every `href()` in the app is checked against the table's linkable
15
+ * patterns. A router that is not `@k8ordo/router` registers its path union
16
+ * directly — `interface Register { path: Route }` with `Route` from `next` —
17
+ * which is also what the framework's generator emitted before `routes`
18
+ * existed. Declared as an interface — the one exception to the repository's
19
+ * type-only rule — because declaration merging is the entire mechanism.
15
20
  */
16
- interface Register {}
17
- /** `href()`'s path constraint: the registered route type, or any `/`-path. */
18
- type RegisteredPath = Register extends {
21
+ export interface Register {}
22
+ type LinkablePatterns<D> = D extends Routes<infer R extends RoutesRecord> ? { [K in NavigablePatternOf<R>]: K; }[NavigablePatternOf<R>] : never;
23
+ /**
24
+ * `Path` when the router a `Register` shape describes accepts it, and what
25
+ * that router accepts when it does not, so the type error names the
26
+ * alternatives: matched against the table's linkable patterns segment by
27
+ * segment when it carries `routes` (refused, the patterns themselves),
28
+ * against the union as given when it carries `path` (refused, that union),
29
+ * and any `/`-path when it carries neither (refused, `` `/${string}` ``). A
30
+ * union of paths is accepted only when every member is. `NavigablePath` is
31
+ * imported as a type only, so `@k8ordo/router` is never loaded at runtime
32
+ * and stays an optional peer.
33
+ */
34
+ export type AcceptedPath<R, Path extends string> = R extends {
35
+ routes: infer Table;
36
+ } ? [Path] extends [NavigablePath<Table, Path>] ? Path : LinkablePatterns<Table> : R extends {
19
37
  path: infer P extends string;
20
- } ? P : `/${string}`;
21
- //#endregion
22
- export { Register, RegisteredPath };
38
+ } ? [Path] extends [P] ? Path : P : [Path] extends [`/${string}`] ? Path : `/${string}`;
39
+ /** `href()`'s path check: `Path` when the registered router accepts it. */
40
+ export type RegisteredPath<Path extends string> = AcceptedPath<Register, Path>;
41
+ //#endregion
@@ -0,0 +1,48 @@
1
+ import { StateSchema, StateValues } from "../schema/object.mjs";
2
+ import { StoredCodec } from "../entry/codec.mjs";
3
+ import { input } from "zod/v4/core";
4
+ //#region src/row/codec.d.ts
5
+ /**
6
+ * How a local or cookie state reads rows an older version of its schema
7
+ * wrote. Without it, such a row salvages field by field and every field the
8
+ * schema no longer accepts lands on its default.
9
+ */
10
+ export type Versioning<Schema extends StateSchema = StateSchema> = {
11
+ /**
12
+ * The version rows are written with now: a positive integer, raised when the
13
+ * stored shape changes in a way salvage cannot carry across.
14
+ */
15
+ version: number;
16
+ /**
17
+ * Turns the values a row of an older version held into this version's.
18
+ * `fromVersion` is the version the row was written with — `0` for a row
19
+ * written before the definition declared a version. The result passes the
20
+ * schema field by field like any read, so its keys are the schema's but its
21
+ * values may be anything: map what you can, hand old values over as they
22
+ * are, and leave the rest out. A throw reads as nothing stored and leaves
23
+ * the row as it was.
24
+ */
25
+ migrate: (old: Readonly<Record<string, unknown>>, fromVersion: number) => { readonly [Key in keyof input<Schema>]?: unknown; };
26
+ };
27
+ /**
28
+ * The codec of a stored row — a Web Storage row or a cookie value, JSON
29
+ * either way. A versioned row is the pair `[version, values]`: an array is
30
+ * never what an unversioned row holds (the values are always an object), so
31
+ * the two never read as each other.
32
+ */
33
+ export type RowCodec = Omit<StoredCodec, 'parse'> & {
34
+ /**
35
+ * The values in a parsed row. `stale` when the row was written by an older
36
+ * version and migrated here, so the caller writes it back.
37
+ */
38
+ read: (row: unknown) => {
39
+ values: StateValues;
40
+ stale: boolean;
41
+ };
42
+ /** The JSON-ready row for these values. */
43
+ row: (values: Readonly<StateValues>) => unknown;
44
+ /** The version rows are written with, `undefined` when unversioned. */
45
+ version: number | undefined;
46
+ };
47
+ export declare const createRowCodec: (schema: StateSchema, slot: string, key: string, versioning: Versioning | undefined) => RowCodec;
48
+ //#endregion
@@ -0,0 +1,60 @@
1
+ import { isRecord } from "../schema/object.mjs";
2
+ import { createStoredCodec } from "../entry/codec.mjs";
3
+ //#region src/row/codec.ts
4
+ const envelopeOf = (row) => {
5
+ if (isRecord(row)) return {
6
+ fromVersion: 0,
7
+ values: row
8
+ };
9
+ if (Array.isArray(row) && row.length === 2 && Number.isSafeInteger(row[0]) && isRecord(row[1])) return {
10
+ fromVersion: row[0],
11
+ values: row[1]
12
+ };
13
+ return null;
14
+ };
15
+ const createRowCodec = (schema, slot, key, versioning) => {
16
+ const stored = createStoredCodec(schema, slot);
17
+ if (versioning === void 0) return {
18
+ ...stored,
19
+ read: (row) => ({
20
+ values: stored.parse(row),
21
+ stale: false
22
+ }),
23
+ row: (values) => values,
24
+ version: void 0
25
+ };
26
+ const { version, migrate } = versioning;
27
+ if (!Number.isSafeInteger(version) || version < 1) throw new TypeError(`"${key}" version must be a positive integer, got ${String(version)}`);
28
+ const read = (row) => {
29
+ const envelope = envelopeOf(row);
30
+ if (envelope === null) return {
31
+ values: stored.parse(void 0),
32
+ stale: false
33
+ };
34
+ if (envelope.fromVersion >= version) return {
35
+ values: stored.parse(envelope.values),
36
+ stale: false
37
+ };
38
+ let migrated;
39
+ try {
40
+ migrated = migrate(envelope.values, envelope.fromVersion);
41
+ } catch {
42
+ return {
43
+ values: stored.parse(void 0),
44
+ stale: false
45
+ };
46
+ }
47
+ return {
48
+ values: stored.parse(migrated),
49
+ stale: true
50
+ };
51
+ };
52
+ return {
53
+ ...stored,
54
+ read,
55
+ row: (values) => [version, values],
56
+ version
57
+ };
58
+ };
59
+ //#endregion
60
+ export { createRowCodec };
@@ -5,17 +5,17 @@ import { $ZodObject, $ZodShape, $ZodType } from "zod/v4/core";
5
5
  * core object and both expose `shape`. Accepting this keeps the smaller entry
6
6
  * usable without losing the types.
7
7
  */
8
- type StateSchema<Shape extends $ZodShape = $ZodShape> = $ZodObject<Shape> & {
8
+ export type StateSchema<Shape extends $ZodShape = $ZodShape> = $ZodObject<Shape> & {
9
9
  shape: Shape;
10
10
  };
11
- type StateValues = Record<string, unknown>;
12
- type SchemaInfo = {
11
+ export type StateValues = Record<string, unknown>;
12
+ export type SchemaInfo = {
13
13
  schema: StateSchema;
14
14
  shape: Readonly<Record<string, $ZodType>>;
15
15
  keys: readonly string[];
16
16
  defaults: Readonly<StateValues>;
17
17
  };
18
- declare const isRecord: (value: unknown) => value is StateValues;
18
+ export declare const isRecord: (value: unknown) => value is StateValues;
19
19
  /**
20
20
  * Structural equality over what schemas produce: scalars via `Object.is`,
21
21
  * arrays and plain objects recursively. Anything else (Date, Map, class
@@ -23,13 +23,12 @@ declare const isRecord: (value: unknown) => value is StateValues;
23
23
  * two structurally silent objects can still differ (a Date's time lives in
24
24
  * an internal slot, not in enumerable keys).
25
25
  */
26
- declare const sameValue: (a: unknown, b: unknown) => boolean;
27
- declare const analyzeSchema: (schema: StateSchema, slot: string) => SchemaInfo;
26
+ export declare const sameValue: (a: unknown, b: unknown) => boolean;
27
+ export declare const analyzeSchema: (schema: StateSchema, slot: string) => SchemaInfo;
28
28
  /**
29
29
  * Whole-parse first; when that fails, the values are user-editable or stale
30
30
  * input, so one broken field falls back to its own default instead of taking
31
31
  * the rest down with it.
32
32
  */
33
- declare const parseWithSalvage: (info: SchemaInfo, raw: StateValues) => StateValues;
34
- //#endregion
35
- export { SchemaInfo, StateSchema, StateValues, analyzeSchema, isRecord, parseWithSalvage, sameValue };
33
+ export declare const parseWithSalvage: (info: SchemaInfo, raw: StateValues) => StateValues;
34
+ //#endregion