@k8ordo/state 0.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/LICENSE +21 -0
- package/dist/entry/codec.d.mts +18 -0
- package/dist/entry/codec.mjs +31 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +6 -0
- package/dist/local-state.d.mts +19 -0
- package/dist/local-state.mjs +19 -0
- package/dist/memory-state.d.mts +17 -0
- package/dist/memory-state.mjs +8 -0
- package/dist/page-state.d.mts +53 -0
- package/dist/page-state.mjs +37 -0
- package/dist/register.d.mts +22 -0
- package/dist/register.mjs +1 -0
- package/dist/schema/object.d.mts +35 -0
- package/dist/schema/object.mjs +63 -0
- package/dist/store/core.d.mts +54 -0
- package/dist/store/core.mjs +106 -0
- package/dist/store/local-store.d.mts +9 -0
- package/dist/store/local-store.mjs +80 -0
- package/dist/store/memory-store.d.mts +9 -0
- package/dist/store/memory-store.mjs +28 -0
- package/dist/store/page-store.d.mts +21 -0
- package/dist/store/page-store.mjs +137 -0
- package/dist/store/registry.d.mts +12 -0
- package/dist/store/registry.mjs +0 -0
- package/dist/url/codec.d.mts +15 -0
- package/dist/url/codec.mjs +62 -0
- package/dist/use-app-state.d.mts +24 -0
- package/dist/use-app-state.mjs +44 -0
- package/docs/GUIDE.md +308 -0
- package/docs/llms.txt +42 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Koki Sakano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StateSchema, StateValues } from "../schema/object.mjs";
|
|
2
|
+
//#region src/entry/codec.d.ts
|
|
3
|
+
type StoredCodec = {
|
|
4
|
+
keys: readonly string[];
|
|
5
|
+
defaults: Readonly<StateValues>;
|
|
6
|
+
parse: (stored: unknown) => StateValues;
|
|
7
|
+
/** Runs already-typed values through the schema, salvage included. */
|
|
8
|
+
salvage: (values: Readonly<StateValues>) => StateValues;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The read side shared by every slot that stores typed values (history entry
|
|
12
|
+
* state, localStorage JSON): no string serialization to derive — only care
|
|
13
|
+
* that what comes back may have been written by an older schema, or by
|
|
14
|
+
* nobody.
|
|
15
|
+
*/
|
|
16
|
+
declare const createStoredCodec: (schema: StateSchema, slot: string) => StoredCodec;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { StoredCodec, createStoredCodec };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { analyzeSchema, isRecord, parseWithSalvage } from "../schema/object.mjs";
|
|
2
|
+
//#region src/entry/codec.ts
|
|
3
|
+
/**
|
|
4
|
+
* The read side shared by every slot that stores typed values (history entry
|
|
5
|
+
* state, localStorage JSON): no string serialization to derive — only care
|
|
6
|
+
* that what comes back may have been written by an older schema, or by
|
|
7
|
+
* nobody.
|
|
8
|
+
*/
|
|
9
|
+
const createStoredCodec = (schema, slot) => {
|
|
10
|
+
const info = analyzeSchema(schema, slot);
|
|
11
|
+
const parse = (stored) => {
|
|
12
|
+
const raw = {};
|
|
13
|
+
if (isRecord(stored)) {
|
|
14
|
+
for (const key of info.keys) if (key in stored) raw[key] = stored[key];
|
|
15
|
+
}
|
|
16
|
+
return parseWithSalvage(info, raw);
|
|
17
|
+
};
|
|
18
|
+
const salvage = (values) => {
|
|
19
|
+
const own = {};
|
|
20
|
+
for (const key of info.keys) if (key in values) own[key] = values[key];
|
|
21
|
+
return parseWithSalvage(info, own);
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
keys: info.keys,
|
|
25
|
+
defaults: info.defaults,
|
|
26
|
+
parse,
|
|
27
|
+
salvage
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { createStoredCodec };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { StateSchema } from "./schema/object.mjs";
|
|
2
|
+
import { Register, RegisteredPath } from "./register.mjs";
|
|
3
|
+
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 { MemoryState, defineMemoryState } from "./memory-state.mjs";
|
|
7
|
+
import { UpdateHandle } from "./store/core.mjs";
|
|
8
|
+
import { UpdateOptions } from "./store/page-store.mjs";
|
|
9
|
+
import { AnyState, useAppState } from "./use-app-state.mjs";
|
|
10
|
+
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 };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { definePageState } from "./page-state.mjs";
|
|
2
|
+
import { defineLocalState } from "./local-state.mjs";
|
|
3
|
+
import { defineMemoryState } from "./memory-state.mjs";
|
|
4
|
+
import { resetStateRegistry } from "./store/registry.mjs";
|
|
5
|
+
import { useAppState } from "./use-app-state.mjs";
|
|
6
|
+
export { defineLocalState, defineMemoryState, definePageState, resetStateRegistry, useAppState };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { StateSchema } from "./schema/object.mjs";
|
|
2
|
+
import { StoredCodec } from "./entry/codec.mjs";
|
|
3
|
+
//#region src/local-state.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* App-scope state that lives in localStorage: device-persistent, shared
|
|
6
|
+
* across tabs, never page-bound — which is why it is its own definition kind
|
|
7
|
+
* instead of a slot on a page state.
|
|
8
|
+
*/
|
|
9
|
+
type LocalState<Schema extends StateSchema = StateSchema> = {
|
|
10
|
+
kind: 'local';
|
|
11
|
+
/** Identity of this state: the storage key and the store registry slot. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** The schema as passed. */
|
|
14
|
+
schema: Schema;
|
|
15
|
+
};
|
|
16
|
+
declare const localCodecOf: (def: LocalState) => StoredCodec;
|
|
17
|
+
declare const defineLocalState: <Schema extends StateSchema>(key: string, schema: Schema) => LocalState<Schema>;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { LocalState, defineLocalState, localCodecOf };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createStoredCodec } from "./entry/codec.mjs";
|
|
2
|
+
//#region src/local-state.ts
|
|
3
|
+
const codecs = /* @__PURE__ */ new WeakMap();
|
|
4
|
+
const localCodecOf = (def) => {
|
|
5
|
+
const codec = codecs.get(def);
|
|
6
|
+
if (codec === void 0) throw new TypeError(`"${def.key}" was not created by defineLocalState of this module instance`);
|
|
7
|
+
return codec;
|
|
8
|
+
};
|
|
9
|
+
const defineLocalState = (key, schema) => {
|
|
10
|
+
const def = {
|
|
11
|
+
kind: "local",
|
|
12
|
+
key,
|
|
13
|
+
schema
|
|
14
|
+
};
|
|
15
|
+
codecs.set(def, createStoredCodec(schema, "local"));
|
|
16
|
+
return def;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { defineLocalState, localCodecOf };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StateValues } from "./schema/object.mjs";
|
|
2
|
+
//#region src/memory-state.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A typed shared box that lives for the JS runtime and resets on reload.
|
|
5
|
+
* The one kind with no schema: its values never leave the runtime, so there
|
|
6
|
+
* is no boundary to re-validate at — a typed `update` is the only writer.
|
|
7
|
+
*/
|
|
8
|
+
type MemoryState<Values extends StateValues = StateValues> = {
|
|
9
|
+
kind: 'memory';
|
|
10
|
+
/** Identity of this state: the store registry slot. */
|
|
11
|
+
key: string;
|
|
12
|
+
/** The initial values; also what SSR renders. */
|
|
13
|
+
initial: Readonly<Values>;
|
|
14
|
+
};
|
|
15
|
+
declare const defineMemoryState: <Values extends StateValues>(key: string, initial: Values) => MemoryState<Values>;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { MemoryState, defineMemoryState };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { StateSchema } from "./schema/object.mjs";
|
|
2
|
+
import { StoredCodec } from "./entry/codec.mjs";
|
|
3
|
+
import { RegisteredPath } from "./register.mjs";
|
|
4
|
+
import { UrlCodec, UrlInput } from "./url/codec.mjs";
|
|
5
|
+
import { output } from "zod/v4/core";
|
|
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> = {
|
|
9
|
+
kind: 'page';
|
|
10
|
+
/** Identity of this state: store registry slot, entry-state namespace. */
|
|
11
|
+
key: string;
|
|
12
|
+
/** The url schema as passed — feed it to `@k8ordo/form` for GET forms. */
|
|
13
|
+
url: Url;
|
|
14
|
+
/** The entry schema as passed. */
|
|
15
|
+
entry: Entry;
|
|
16
|
+
/** Reads the url slot on the server. Absent params get their defaults. */
|
|
17
|
+
parseUrl: (input: UrlInput) => OutputOf<Url>;
|
|
18
|
+
/**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
href: <Path extends RegisteredPath>(base: Path, values?: Readonly<Partial<OutputOf<Url>>>) => Path | `${Path}?${string}`;
|
|
24
|
+
/** The query string alone (no `?`), for handrolled URL composition. */
|
|
25
|
+
search: (values?: Readonly<Partial<OutputOf<Url>>>) => string;
|
|
26
|
+
};
|
|
27
|
+
type SharedKeys<Url, Entry> = Extract<keyof OutputOf<Url>, keyof OutputOf<Entry>> & string;
|
|
28
|
+
/**
|
|
29
|
+
* The flat merged state cannot host one name twice; when it would, the error
|
|
30
|
+
* carries the offending field name instead of a bare "not assignable".
|
|
31
|
+
*/
|
|
32
|
+
type DisjointGuard<Url, Entry> = SharedKeys<Url, Entry> extends never ? unknown : {
|
|
33
|
+
entry: `field declared in both url and entry: ${SharedKeys<Url, Entry>}`;
|
|
34
|
+
};
|
|
35
|
+
type PageInternals = {
|
|
36
|
+
url: UrlCodec | null;
|
|
37
|
+
entry: StoredCodec | null;
|
|
38
|
+
/** url keys first, then entry keys — the flat snapshot's full key set. */
|
|
39
|
+
keys: readonly string[];
|
|
40
|
+
};
|
|
41
|
+
declare const internalsOf: (def: PageState) => PageInternals;
|
|
42
|
+
declare function definePageState<Url extends StateSchema>(key: string, config: {
|
|
43
|
+
url: Url;
|
|
44
|
+
}): PageState<Url, undefined>;
|
|
45
|
+
declare function definePageState<Entry extends StateSchema>(key: string, config: {
|
|
46
|
+
entry: Entry;
|
|
47
|
+
}): PageState<undefined, Entry>;
|
|
48
|
+
declare function definePageState<Url extends StateSchema, Entry extends StateSchema>(key: string, config: {
|
|
49
|
+
url: Url;
|
|
50
|
+
entry: Entry;
|
|
51
|
+
} & DisjointGuard<Url, Entry>): PageState<Url, Entry>;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { OutputOf, PageInternals, PageState, definePageState, internalsOf };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createStoredCodec } from "./entry/codec.mjs";
|
|
2
|
+
import { createUrlCodec } from "./url/codec.mjs";
|
|
3
|
+
//#region src/page-state.ts
|
|
4
|
+
const internals = /* @__PURE__ */ new WeakMap();
|
|
5
|
+
const internalsOf = (def) => {
|
|
6
|
+
const found = internals.get(def);
|
|
7
|
+
if (found === void 0) throw new TypeError(`"${def.key}" was not created by definePageState of this module instance`);
|
|
8
|
+
return found;
|
|
9
|
+
};
|
|
10
|
+
function definePageState(key, config) {
|
|
11
|
+
const url = config.url === void 0 ? null : createUrlCodec(config.url);
|
|
12
|
+
const entry = config.entry === void 0 ? null : createStoredCodec(config.entry, "entry");
|
|
13
|
+
if (url === null && entry === null) throw new TypeError(`"${key}" declares neither url nor entry`);
|
|
14
|
+
const shared = url === null || entry === null ? [] : url.keys.filter((field) => entry.keys.includes(field));
|
|
15
|
+
if (shared.length > 0) throw new TypeError(`"${key}" declares in both url and entry: ${shared.join(", ")}`);
|
|
16
|
+
const search = (values) => url === null ? "" : url.search(values ?? {});
|
|
17
|
+
const def = {
|
|
18
|
+
kind: "page",
|
|
19
|
+
key,
|
|
20
|
+
url: config.url,
|
|
21
|
+
entry: config.entry,
|
|
22
|
+
parseUrl: (input) => url === null ? {} : url.parse(input),
|
|
23
|
+
search,
|
|
24
|
+
href: (base, values) => {
|
|
25
|
+
const query = search(values);
|
|
26
|
+
return query === "" ? base : `${base}?${query}`;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
internals.set(def, {
|
|
30
|
+
url,
|
|
31
|
+
entry,
|
|
32
|
+
keys: [...url?.keys ?? [], ...entry?.keys ?? []]
|
|
33
|
+
});
|
|
34
|
+
return def;
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { definePageState, internalsOf };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/register.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The app-side hook for typed route paths. An application augments this once:
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import type { Route } from 'next';
|
|
7
|
+
* declare module '@k8ordo/state' {
|
|
8
|
+
* interface Register { path: Route }
|
|
9
|
+
* }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
interface Register {}
|
|
17
|
+
/** `href()`'s path constraint: the registered route type, or any `/`-path. */
|
|
18
|
+
type RegisteredPath = Register extends {
|
|
19
|
+
path: infer P extends string;
|
|
20
|
+
} ? P : `/${string}`;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { Register, RegisteredPath };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { $ZodObject, $ZodShape, $ZodType } from "zod/v4/core";
|
|
2
|
+
//#region src/schema/object.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The common ground between `zod` and `zod/mini`: both build on the shared
|
|
5
|
+
* core object and both expose `shape`. Accepting this keeps the smaller entry
|
|
6
|
+
* usable without losing the types.
|
|
7
|
+
*/
|
|
8
|
+
type StateSchema<Shape extends $ZodShape = $ZodShape> = $ZodObject<Shape> & {
|
|
9
|
+
shape: Shape;
|
|
10
|
+
};
|
|
11
|
+
type StateValues = Record<string, unknown>;
|
|
12
|
+
type SchemaInfo = {
|
|
13
|
+
schema: StateSchema;
|
|
14
|
+
shape: Readonly<Record<string, $ZodType>>;
|
|
15
|
+
keys: readonly string[];
|
|
16
|
+
defaults: Readonly<StateValues>;
|
|
17
|
+
};
|
|
18
|
+
declare const isRecord: (value: unknown) => value is StateValues;
|
|
19
|
+
/**
|
|
20
|
+
* Structural equality over what schemas produce: scalars via `Object.is`,
|
|
21
|
+
* arrays and plain objects recursively. Anything else (Date, Map, class
|
|
22
|
+
* instances) compares by reference only — conservatively "changed" — because
|
|
23
|
+
* two structurally silent objects can still differ (a Date's time lives in
|
|
24
|
+
* an internal slot, not in enumerable keys).
|
|
25
|
+
*/
|
|
26
|
+
declare const sameValue: (a: unknown, b: unknown) => boolean;
|
|
27
|
+
declare const analyzeSchema: (schema: StateSchema, slot: string) => SchemaInfo;
|
|
28
|
+
/**
|
|
29
|
+
* Whole-parse first; when that fails, the values are user-editable or stale
|
|
30
|
+
* input, so one broken field falls back to its own default instead of taking
|
|
31
|
+
* the rest down with it.
|
|
32
|
+
*/
|
|
33
|
+
declare const parseWithSalvage: (info: SchemaInfo, raw: StateValues) => StateValues;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { SchemaInfo, StateSchema, StateValues, analyzeSchema, isRecord, parseWithSalvage, sameValue };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { safeParse } from "zod/v4/core";
|
|
2
|
+
//#region src/schema/object.ts
|
|
3
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const isPlainRecord = (value) => {
|
|
5
|
+
if (typeof value !== "object" || value === null) return false;
|
|
6
|
+
const proto = Object.getPrototypeOf(value);
|
|
7
|
+
return proto === Object.prototype || proto === null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Structural equality over what schemas produce: scalars via `Object.is`,
|
|
11
|
+
* arrays and plain objects recursively. Anything else (Date, Map, class
|
|
12
|
+
* instances) compares by reference only — conservatively "changed" — because
|
|
13
|
+
* two structurally silent objects can still differ (a Date's time lives in
|
|
14
|
+
* an internal slot, not in enumerable keys).
|
|
15
|
+
*/
|
|
16
|
+
const sameValue = (a, b) => {
|
|
17
|
+
if (Object.is(a, b)) return true;
|
|
18
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((value, index) => sameValue(value, b[index]));
|
|
19
|
+
if (isPlainRecord(a) && isPlainRecord(b)) {
|
|
20
|
+
const aKeys = Object.keys(a);
|
|
21
|
+
const bKeys = Object.keys(b);
|
|
22
|
+
return aKeys.length === bKeys.length && aKeys.every((key) => key in b && sameValue(a[key], b[key]));
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
const analyzeSchema = (schema, slot) => {
|
|
27
|
+
const shape = schema.shape;
|
|
28
|
+
const keys = Object.keys(shape);
|
|
29
|
+
const empty = safeParse(schema, {});
|
|
30
|
+
if (!empty.success) {
|
|
31
|
+
const failing = keys.filter((key) => !safeParse(shape[key], void 0).success);
|
|
32
|
+
throw new TypeError(failing.length > 0 ? `${slot} fields must tolerate absence — add .default() or .optional() to: ${failing.join(", ")}` : `${slot} schema rejects its own defaults — an object-level refine must accept the all-defaults value`);
|
|
33
|
+
}
|
|
34
|
+
const defaults = {};
|
|
35
|
+
for (const key of keys) defaults[key] = empty.data[key];
|
|
36
|
+
return {
|
|
37
|
+
schema,
|
|
38
|
+
shape,
|
|
39
|
+
keys,
|
|
40
|
+
defaults
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Whole-parse first; when that fails, the values are user-editable or stale
|
|
45
|
+
* input, so one broken field falls back to its own default instead of taking
|
|
46
|
+
* the rest down with it.
|
|
47
|
+
*/
|
|
48
|
+
const parseWithSalvage = (info, raw) => {
|
|
49
|
+
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 };
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { analyzeSchema, isRecord, parseWithSalvage, sameValue };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { StateValues } from "../schema/object.mjs";
|
|
2
|
+
//#region src/store/core.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Mirrors `navigation.navigate()`'s own return shape: an object holding the
|
|
5
|
+
* promises rather than a promise, so fire-and-forget callers ignore it
|
|
6
|
+
* without tripping no-floating-promises, and the 5% that await pick a stage.
|
|
7
|
+
* Slots with no navigation behind them return both promises pre-settled —
|
|
8
|
+
* the type stays uniform across every kind of state.
|
|
9
|
+
*/
|
|
10
|
+
type UpdateHandle = {
|
|
11
|
+
/** The write is in its home (history entry, storage, memory). */
|
|
12
|
+
committed: Promise<void>;
|
|
13
|
+
/** All downstream work (a router's intercept fetch/render) is done. */
|
|
14
|
+
finished: Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
type Patch = StateValues | ((current: StateValues) => StateValues);
|
|
17
|
+
type Store = {
|
|
18
|
+
subscribe: (keys: readonly string[] | null, notify: () => void) => () => void;
|
|
19
|
+
getSnapshot: (sig: string, keys: readonly string[] | null) => StateValues;
|
|
20
|
+
update: (patch: Patch, options?: {
|
|
21
|
+
history?: 'push' | 'replace';
|
|
22
|
+
}) => UpdateHandle;
|
|
23
|
+
dispose: () => void;
|
|
24
|
+
};
|
|
25
|
+
type Handle = {
|
|
26
|
+
external: UpdateHandle;
|
|
27
|
+
settle: () => void;
|
|
28
|
+
fail: (error: unknown) => void;
|
|
29
|
+
adopt: (result: {
|
|
30
|
+
committed?: Promise<unknown> | undefined;
|
|
31
|
+
finished?: Promise<unknown> | undefined;
|
|
32
|
+
}) => void;
|
|
33
|
+
};
|
|
34
|
+
declare const createHandle: () => Handle;
|
|
35
|
+
type StoreCore = {
|
|
36
|
+
snapshot: () => StateValues;
|
|
37
|
+
applyNext: (next: StateValues) => void;
|
|
38
|
+
subscribe: Store['subscribe'];
|
|
39
|
+
getSnapshot: Store['getSnapshot'];
|
|
40
|
+
clear: () => void;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The shared half of every store: an immutable snapshot over a fixed key set,
|
|
44
|
+
* exact per-key change detection, and notification only to the listeners
|
|
45
|
+
* whose subscribed keys actually changed.
|
|
46
|
+
*/
|
|
47
|
+
declare const createStoreCore: (keys: readonly string[], initial: StateValues) => StoreCore;
|
|
48
|
+
/**
|
|
49
|
+
* The unknown-field guard every store shares: the typed API already prevents
|
|
50
|
+
* this, so reaching it means an untyped caller — fail loudly, not silently.
|
|
51
|
+
*/
|
|
52
|
+
declare const resolvePatch: (patch: Patch, current: StateValues, keys: readonly string[], label: string) => StateValues;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { Handle, Patch, Store, StoreCore, UpdateHandle, createHandle, createStoreCore, resolvePatch };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { sameValue } from "../schema/object.mjs";
|
|
2
|
+
//#region src/store/core.ts
|
|
3
|
+
const createHandle = () => {
|
|
4
|
+
let resolveCommitted;
|
|
5
|
+
let rejectCommitted;
|
|
6
|
+
let resolveFinished;
|
|
7
|
+
let rejectFinished;
|
|
8
|
+
const committed = new Promise((resolve, reject) => {
|
|
9
|
+
resolveCommitted = resolve;
|
|
10
|
+
rejectCommitted = reject;
|
|
11
|
+
});
|
|
12
|
+
const finished = new Promise((resolve, reject) => {
|
|
13
|
+
resolveFinished = resolve;
|
|
14
|
+
rejectFinished = reject;
|
|
15
|
+
});
|
|
16
|
+
committed.catch(() => void 0);
|
|
17
|
+
finished.catch(() => void 0);
|
|
18
|
+
return {
|
|
19
|
+
external: {
|
|
20
|
+
committed,
|
|
21
|
+
finished
|
|
22
|
+
},
|
|
23
|
+
settle: () => {
|
|
24
|
+
resolveCommitted();
|
|
25
|
+
resolveFinished();
|
|
26
|
+
},
|
|
27
|
+
fail: (error) => {
|
|
28
|
+
rejectCommitted(error);
|
|
29
|
+
rejectFinished(error);
|
|
30
|
+
},
|
|
31
|
+
adopt: (result) => {
|
|
32
|
+
if (result.committed === void 0) resolveCommitted();
|
|
33
|
+
else result.committed.then(() => resolveCommitted(), rejectCommitted);
|
|
34
|
+
if (result.finished === void 0) resolveFinished();
|
|
35
|
+
else result.finished.then(() => resolveFinished(), rejectFinished);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The shared half of every store: an immutable snapshot over a fixed key set,
|
|
41
|
+
* exact per-key change detection, and notification only to the listeners
|
|
42
|
+
* whose subscribed keys actually changed.
|
|
43
|
+
*/
|
|
44
|
+
const createStoreCore = (keys, initial) => {
|
|
45
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
46
|
+
const picks = /* @__PURE__ */ new Map();
|
|
47
|
+
let snapshot = initial;
|
|
48
|
+
return {
|
|
49
|
+
snapshot: () => snapshot,
|
|
50
|
+
applyNext: (next) => {
|
|
51
|
+
const changed = [];
|
|
52
|
+
const merged = {};
|
|
53
|
+
for (const key of keys) if (sameValue(snapshot[key], next[key])) merged[key] = snapshot[key];
|
|
54
|
+
else {
|
|
55
|
+
merged[key] = next[key];
|
|
56
|
+
changed.push(key);
|
|
57
|
+
}
|
|
58
|
+
if (changed.length === 0) return;
|
|
59
|
+
snapshot = merged;
|
|
60
|
+
for (const [sig, pick] of picks) if (changed.some((key) => pick.keys.includes(key))) picks.delete(sig);
|
|
61
|
+
for (const listener of listeners) {
|
|
62
|
+
const subscribed = listener.keys;
|
|
63
|
+
if (subscribed === null || changed.some((key) => subscribed.has(key))) listener.notify();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
subscribe: (subscribedKeys, notify) => {
|
|
67
|
+
const listener = {
|
|
68
|
+
keys: subscribedKeys === null ? null : new Set(subscribedKeys),
|
|
69
|
+
notify
|
|
70
|
+
};
|
|
71
|
+
listeners.add(listener);
|
|
72
|
+
return () => {
|
|
73
|
+
listeners.delete(listener);
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
getSnapshot: (sig, pickKeys) => {
|
|
77
|
+
if (pickKeys === null) return snapshot;
|
|
78
|
+
let pick = picks.get(sig);
|
|
79
|
+
if (pick === void 0) {
|
|
80
|
+
const value = {};
|
|
81
|
+
for (const key of pickKeys) value[key] = snapshot[key];
|
|
82
|
+
pick = {
|
|
83
|
+
keys: pickKeys,
|
|
84
|
+
value
|
|
85
|
+
};
|
|
86
|
+
picks.set(sig, pick);
|
|
87
|
+
}
|
|
88
|
+
return pick.value;
|
|
89
|
+
},
|
|
90
|
+
clear: () => {
|
|
91
|
+
listeners.clear();
|
|
92
|
+
picks.clear();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* The unknown-field guard every store shares: the typed API already prevents
|
|
98
|
+
* this, so reaching it means an untyped caller — fail loudly, not silently.
|
|
99
|
+
*/
|
|
100
|
+
const resolvePatch = (patch, current, keys, label) => {
|
|
101
|
+
const resolved = typeof patch === "function" ? patch({ ...current }) : patch;
|
|
102
|
+
for (const key of Object.keys(resolved)) if (!keys.includes(key)) throw new TypeError(`"${label}" has no field "${key}"`);
|
|
103
|
+
return resolved;
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
export { createHandle, createStoreCore, resolvePatch };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StateValues } from "../schema/object.mjs";
|
|
2
|
+
import { LocalState } from "../local-state.mjs";
|
|
3
|
+
import { Store } from "./core.mjs";
|
|
4
|
+
//#region src/store/local-store.d.ts
|
|
5
|
+
declare const localStoreOf: (def: LocalState) => Store;
|
|
6
|
+
/** SSR and hydration see the defaults — the server has no localStorage. */
|
|
7
|
+
declare const localInitialSnapshot: (def: LocalState) => StateValues;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { localInitialSnapshot, localStoreOf };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { localCodecOf } from "../local-state.mjs";
|
|
2
|
+
import { createHandle, createStoreCore, resolvePatch } from "./core.mjs";
|
|
3
|
+
import { getOrCreateStore } from "./registry.mjs";
|
|
4
|
+
//#region src/store/local-store.ts
|
|
5
|
+
const storageKeyOf = (def) => `k8ordo-state:${def.key}`;
|
|
6
|
+
const createLocalStore = (def) => {
|
|
7
|
+
const codec = localCodecOf(def);
|
|
8
|
+
const storageKey = storageKeyOf(def);
|
|
9
|
+
const read = () => {
|
|
10
|
+
const text = localStorage.getItem(storageKey);
|
|
11
|
+
if (text === null) return codec.parse(void 0);
|
|
12
|
+
try {
|
|
13
|
+
return codec.parse(JSON.parse(text));
|
|
14
|
+
} catch {
|
|
15
|
+
return codec.parse(void 0);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const core = createStoreCore(codec.keys, read());
|
|
19
|
+
let pending = null;
|
|
20
|
+
let handle = null;
|
|
21
|
+
const onStorage = (event) => {
|
|
22
|
+
if (event.storageArea !== localStorage) return;
|
|
23
|
+
if (event.key !== storageKey && event.key !== null) return;
|
|
24
|
+
const fresh = read();
|
|
25
|
+
core.applyNext(pending === null ? fresh : {
|
|
26
|
+
...fresh,
|
|
27
|
+
...pending
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
window.addEventListener("storage", onStorage);
|
|
31
|
+
const flush = () => {
|
|
32
|
+
const patch = pending;
|
|
33
|
+
const current = handle;
|
|
34
|
+
pending = null;
|
|
35
|
+
handle = null;
|
|
36
|
+
const target = codec.salvage({
|
|
37
|
+
...read(),
|
|
38
|
+
...patch
|
|
39
|
+
});
|
|
40
|
+
const values = {};
|
|
41
|
+
for (const key of codec.keys) values[key] = target[key];
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(storageKey, JSON.stringify(values));
|
|
44
|
+
core.applyNext(target);
|
|
45
|
+
current.settle();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
current.fail(error);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const update = (patch) => {
|
|
51
|
+
const resolved = resolvePatch(patch, core.snapshot(), codec.keys, def.key);
|
|
52
|
+
if (pending === null) {
|
|
53
|
+
pending = {};
|
|
54
|
+
handle = createHandle();
|
|
55
|
+
queueMicrotask(flush);
|
|
56
|
+
}
|
|
57
|
+
Object.assign(pending, resolved);
|
|
58
|
+
const echoed = codec.salvage({
|
|
59
|
+
...core.snapshot(),
|
|
60
|
+
...pending
|
|
61
|
+
});
|
|
62
|
+
for (const key of Object.keys(pending)) pending[key] = echoed[key];
|
|
63
|
+
core.applyNext(echoed);
|
|
64
|
+
return handle.external;
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
subscribe: core.subscribe,
|
|
68
|
+
getSnapshot: core.getSnapshot,
|
|
69
|
+
update,
|
|
70
|
+
dispose: () => {
|
|
71
|
+
window.removeEventListener("storage", onStorage);
|
|
72
|
+
core.clear();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const localStoreOf = (def) => getOrCreateStore("local", def.key, () => createLocalStore(def));
|
|
77
|
+
/** SSR and hydration see the defaults — the server has no localStorage. */
|
|
78
|
+
const localInitialSnapshot = (def) => ({ ...localCodecOf(def).defaults });
|
|
79
|
+
//#endregion
|
|
80
|
+
export { localInitialSnapshot, localStoreOf };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StateValues } from "../schema/object.mjs";
|
|
2
|
+
import { MemoryState } from "../memory-state.mjs";
|
|
3
|
+
import { Store } from "./core.mjs";
|
|
4
|
+
//#region src/store/memory-store.d.ts
|
|
5
|
+
declare const memoryStoreOf: (def: MemoryState) => Store;
|
|
6
|
+
/** SSR renders the initial values — static, so no cross-request concern. */
|
|
7
|
+
declare const memoryInitialSnapshot: (def: MemoryState) => StateValues;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { memoryInitialSnapshot, memoryStoreOf };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHandle, createStoreCore, resolvePatch } from "./core.mjs";
|
|
2
|
+
import { getOrCreateStore } from "./registry.mjs";
|
|
3
|
+
//#region src/store/memory-store.ts
|
|
4
|
+
const createMemoryStore = (def) => {
|
|
5
|
+
const keys = Object.keys(def.initial);
|
|
6
|
+
const core = createStoreCore(keys, { ...def.initial });
|
|
7
|
+
const update = (patch) => {
|
|
8
|
+
const resolved = resolvePatch(patch, core.snapshot(), keys, def.key);
|
|
9
|
+
core.applyNext({
|
|
10
|
+
...core.snapshot(),
|
|
11
|
+
...resolved
|
|
12
|
+
});
|
|
13
|
+
const handle = createHandle();
|
|
14
|
+
handle.settle();
|
|
15
|
+
return handle.external;
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
subscribe: core.subscribe,
|
|
19
|
+
getSnapshot: core.getSnapshot,
|
|
20
|
+
update,
|
|
21
|
+
dispose: core.clear
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const memoryStoreOf = (def) => getOrCreateStore("memory", def.key, () => createMemoryStore(def));
|
|
25
|
+
/** SSR renders the initial values — static, so no cross-request concern. */
|
|
26
|
+
const memoryInitialSnapshot = (def) => ({ ...def.initial });
|
|
27
|
+
//#endregion
|
|
28
|
+
export { memoryInitialSnapshot, memoryStoreOf };
|