@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 +53 -28
- package/dist/base.d.mts +9 -0
- package/dist/base.mjs +15 -0
- package/dist/cookie-state.d.mts +38 -0
- package/dist/cookie-state.mjs +35 -0
- package/dist/entry/codec.d.mts +3 -4
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +4 -3
- package/dist/memory-state.d.mts +3 -4
- package/dist/page-state.d.mts +21 -11
- package/dist/page-state.mjs +17 -3
- package/dist/register.d.mts +25 -19
- package/dist/row/codec.d.mts +48 -0
- package/dist/row/codec.mjs +60 -0
- package/dist/schema/object.d.mts +8 -9
- package/dist/schema/object.mjs +10 -11
- package/dist/storage-state.d.mts +38 -0
- package/dist/storage-state.mjs +37 -0
- package/dist/store/cookie-store.d.mts +12 -0
- package/dist/store/cookie-store.mjs +129 -0
- package/dist/store/core.d.mts +12 -12
- package/dist/store/memory-store.d.mts +3 -4
- package/dist/store/page-store.d.mts +7 -6
- package/dist/store/registry.d.mts +3 -4
- package/dist/store/storage-store.d.mts +9 -0
- package/dist/store/storage-store.mjs +97 -0
- package/dist/url/codec.d.mts +4 -5
- package/dist/url/codec.mjs +32 -11
- package/dist/use-app-state.d.mts +15 -6
- package/dist/use-app-state.mjs +16 -17
- package/docs/GUIDE.md +450 -115
- package/docs/llms.txt +72 -31
- package/package.json +24 -19
- package/dist/local-state.d.mts +0 -29
- package/dist/local-state.mjs +0 -30
- package/dist/store/local-store.d.mts +0 -9
- package/dist/store/local-store.mjs +0 -79
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
| `
|
|
32
|
-
| `@
|
|
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
|
-
`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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` / `
|
|
127
|
-
shared module, read the url slot
|
|
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
|
|
130
|
-
|
|
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:
|
package/dist/base.d.mts
ADDED
|
@@ -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 };
|
package/dist/entry/codec.d.mts
CHANGED
|
@@ -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 "./
|
|
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 {
|
|
2
|
-
import {
|
|
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 };
|
package/dist/memory-state.d.mts
CHANGED
|
@@ -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
|
package/dist/page-state.d.mts
CHANGED
|
@@ -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
|
|
21
|
-
*
|
|
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
|
|
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 {
|
|
63
|
+
export {};
|
package/dist/page-state.mjs
CHANGED
|
@@ -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: (
|
|
25
|
+
href: (path, values) => {
|
|
25
26
|
const query = search(values);
|
|
26
|
-
|
|
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 };
|
package/dist/register.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
15
|
-
* A router that is not `@k8ordo/router` registers its path union
|
|
16
|
-
* `interface Register { path: Route }` with `Route` from `next` —
|
|
17
|
-
* also what the framework's generator emitted before `routes`
|
|
18
|
-
* Declared as an interface — the one exception to the repository's
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
29
|
-
routes: infer
|
|
30
|
-
} ?
|
|
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
|
|
34
|
-
type 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>;
|
|
35
41
|
//#endregion
|
|
36
|
-
export {
|
|
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 };
|
package/dist/schema/object.d.mts
CHANGED
|
@@ -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
|
package/dist/schema/object.mjs
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 };
|