@k8ordo/state 0.2.0 → 1.1.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 CHANGED
@@ -1,10 +1,13 @@
1
1
  # @k8ordo/state
2
2
 
3
3
  Declare state by where it lives — URL search params, hidden history-entry
4
- state, localStorage, or memory. One zod schema per place derives the
5
- server-side read, canonical links, salvage of stale data, and a client
6
- subscription with exact per-key change detection. Links and GET forms work
7
- before JavaScript loads; imperative URL updates ride the Navigation API.
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.
8
11
 
9
12
  Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
10
13
  Components, uses only what has reached Baseline newly available, and ships no
@@ -23,13 +26,17 @@ pnpm add @k8ordo/state zod
23
26
 
24
27
  ## Peer Dependencies
25
28
 
26
- | Package | Version | Needed for |
27
- | ---------------- | -------- | -------------------------------------------------------- |
28
- | `react` | ≥19.2.6 | `useAppState` |
29
- | `zod` | ^4.4.3 | the schemas (`zod/mini` works, and is the lighter pick) |
30
- | `@k8ordo/router` | ^0.1.0 | optional — typed `href` paths from the app's route table |
31
- | `typescript` | ≥7.0.2 | the shipped type declarations |
32
- | `@types/react` | ≥19.2.18 | the shipped type declarations |
29
+ <!-- peers -->
30
+
31
+ | Package | Version | Required | Needed for |
32
+ | ---------------- | ------- | -------- | ------------------------------------------------------- |
33
+ | `react` | ≥19.3.0 | yes | `useAppState` |
34
+ | `zod` | ^4.6.6 | yes | the schemas (`zod/mini` works, and is the lighter pick) |
35
+ | `@k8ordo/router` | ^1.1.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 -->
33
40
 
34
41
  The schema ships to the browser here — the client parses and serializes with
35
42
  it — so reach for `zod/mini` unless the app already pays for classic `zod`.
@@ -58,7 +65,7 @@ export const prefs = defineLocalState(
58
65
  ```
59
66
 
60
67
  ```tsx
61
- // page.tsx — Server Component
68
+ // page.tsx — a Next.js Server Component
62
69
  import { listState } from './state';
63
70
 
64
71
  export default async function Page({ searchParams }: PageProps<'/products'>) {
@@ -80,6 +87,7 @@ export default async function Page({ searchParams }: PageProps<'/products'>) {
80
87
  // filters.tsx
81
88
  'use client';
82
89
  import { useAppState } from '@k8ordo/state';
90
+
83
91
  import { listState, prefs } from './state';
84
92
 
85
93
  export function Filters({ initialUrl }: FiltersProps) {
@@ -100,16 +108,30 @@ export function Filters({ initialUrl }: FiltersProps) {
100
108
  }
101
109
  ```
102
110
 
103
- `update()` applies synchronously, batches per handler into one write, and
104
- returns `{ committed, finished }` handles. `href` omits every field at its
105
- default, so every link is canonical. A value the schema rejects — a URL a
106
- user edited, a row an older schema wrote — falls back to its own default,
107
- field by field.
108
-
109
- The [design guide](docs/GUIDE.md) covers the rest: the four places and when
110
- each fits, history-entry state, batching and update handles, subscription
111
- granularity, router requirements, typed routes via `Register`, reading local
112
- state before hydration with `inlineRead()`, and GET forms with `@k8ordo/form`.
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`.
113
135
 
114
136
  ## AI Agent Documentation
115
137
 
@@ -120,14 +142,17 @@ Point your agent at them once by pasting this into your project's `CLAUDE.md` /
120
142
  `AGENTS.md`:
121
143
 
122
144
  ```markdown
123
- Use `@k8ordo/state` for URL, history-entry, localStorage and shared memory
124
- state. Before adding or changing state, read
145
+ Use `@k8ordo/state` for URL, history-entry, localStorage, sessionStorage,
146
+ cookie and shared memory state. Before adding or changing state, read
125
147
  `node_modules/@k8ordo/state/docs/GUIDE.md`. Declare each state by where it
126
- lives (`definePageState` / `defineLocalState` / `defineMemoryState`) in a
127
- shared module, read the url slot on the server with `parseUrl`, build links
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
128
152
  with `href`, and subscribe on the client with `useAppState`. Every boundary
129
- field needs a default or `.optional()`; never mirror a definition's values
130
- into React state.
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.
131
156
  ```
132
157
 
133
158
  What each surface gives an agent:
@@ -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,31 @@ 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>;
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>);
52
62
  //#endregion
53
- export { OutputOf, PageInternals, PageState, definePageState, internalsOf };
63
+ export {};
@@ -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,4 +1,4 @@
1
- import { RouteOf } from "@k8ordo/router";
1
+ import { NavigablePath, NavigablePatternOf, Routes, RoutesRecord } from "@k8ordo/router";
2
2
  //#region src/register.d.ts
3
3
  /**
4
4
  * The app-side hook for typed route paths. An application augments this once,
@@ -11,26 +11,32 @@ import { RouteOf } from "@k8ordo/router";
11
11
  * }
12
12
  * ```
13
13
  *
14
- * and every `href()` in the app is constrained to the table's linkable paths.
15
- * A router that is not `@k8ordo/router` registers its path union directly —
16
- * `interface Register { path: Route }` with `Route` from `next` — which is
17
- * also what the framework's generator emitted before `routes` existed.
18
- * Declared as an interface — the one exception to the repository's type-only
19
- * 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.
20
20
  */
21
- interface Register {}
21
+ export interface Register {}
22
+ type LinkablePatterns<D> = D extends Routes<infer R extends RoutesRecord> ? { [K in NavigablePatternOf<R>]: K; }[NavigablePatternOf<R>] : never;
22
23
  /**
23
- * The path union a `Register` shape yields: the table's linkable paths when
24
- * it carries `routes`, the union as given when it carries `path`, and any
25
- * `/`-path when it carries neither. `RouteOf` is imported as a type only, so
26
- * `@k8ordo/router` is never loaded at runtime and stays an optional peer.
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.
27
33
  */
28
- type PathFrom<R> = R extends {
29
- routes: infer Routes;
30
- } ? RouteOf<Routes> : R extends {
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 {
31
37
  path: infer P extends string;
32
- } ? P : `/${string}`;
33
- /** `href()`'s path constraint: the registered route type, or any `/`-path. */
34
- type RegisteredPath = PathFrom<Register>;
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>;
35
41
  //#endregion
36
- export { PathFrom, Register, RegisteredPath };
42
+ export {};
@@ -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
@@ -40,6 +40,11 @@ const analyzeSchema = (schema, slot) => {
40
40
  defaults
41
41
  };
42
42
  };
43
+ const pickKeys = (info, data) => {
44
+ const values = {};
45
+ for (const key of info.keys) values[key] = data[key];
46
+ return values;
47
+ };
43
48
  /**
44
49
  * Whole-parse first; when that fails, the values are user-editable or stale
45
50
  * input, so one broken field falls back to its own default instead of taking
@@ -47,17 +52,11 @@ const analyzeSchema = (schema, slot) => {
47
52
  */
48
53
  const parseWithSalvage = (info, raw) => {
49
54
  const whole = safeParse(info.schema, raw);
50
- if (whole.success) {
51
- const values = {};
52
- for (const key of info.keys) values[key] = whole.data[key];
53
- return values;
54
- }
55
- const salvaged = {};
56
- for (const key of info.keys) if (key in raw) {
57
- const field = safeParse(info.shape[key], raw[key]);
58
- salvaged[key] = field.success ? field.data : info.defaults[key];
59
- } else salvaged[key] = info.defaults[key];
60
- return safeParse(info.schema, salvaged).success ? salvaged : { ...info.defaults };
55
+ if (whole.success) return pickKeys(info, whole.data);
56
+ const kept = {};
57
+ for (const key of info.keys) if (key in raw && safeParse(info.shape[key], raw[key]).success) kept[key] = raw[key];
58
+ const salvaged = safeParse(info.schema, kept);
59
+ return salvaged.success ? pickKeys(info, salvaged.data) : { ...info.defaults };
61
60
  };
62
61
  //#endregion
63
62
  export { analyzeSchema, isRecord, parseWithSalvage, sameValue };