@cosmicdrift/kumiko-headless 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.
@@ -0,0 +1,194 @@
1
+ // Dispatcher Contract — the interface every client-side Dispatcher implements.
2
+ // Two implementations ship with Kumiko:
3
+ //
4
+ // - dispatcher-live: HTTP-only, always online. Writes go straight to
5
+ // /api/write or /api/batch; a failed network call is a failed write.
6
+ // Used on the web by default and on mobile when the app is "cockpit"-
7
+ // style (admin dashboards, back-office UIs).
8
+ //
9
+ // - dispatcher-savable: Local-first with an outbound queue. Writes land
10
+ // in a local store, the queue syncs when the network is back. Used on
11
+ // mobile and for PWA-style web apps.
12
+ //
13
+ // The UI code never reaches for either implementation directly — it takes
14
+ // a `Dispatcher` through a provider/context and calls `write` / `query` /
15
+ // `batch`. A feature-module can therefore be rendered identically on a
16
+ // live-online admin screen and on a mobile app that edits offline; the
17
+ // dispatcher choice is an app-level concern, not a feature-level one.
18
+ //
19
+ // Result shape: `{ isSuccess: true, data } | { isSuccess: false, error }`.
20
+ // Pattern matches the server's WriteResult but does not import it — this
21
+ // package stays free of @cosmicdrift/kumiko-framework so it runs in any JS runtime
22
+ // (browser, Expo/React Native, Web Worker). The HTTP dispatcher adapts
23
+ // the server response to this shape; the savable dispatcher synthesizes
24
+ // it from its own state machine. The shape is the boundary, not the
25
+ // internal detail.
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Result envelopes
29
+ // ---------------------------------------------------------------------------
30
+
31
+ // A validation failure issue pinned to a specific payload field. Paths follow
32
+ // the same dotted convention the server uses (see kumiko errors/classes.ts),
33
+ // so form-controllers can map `tasks.2.title` back to the right sub-line's
34
+ // input without any translation.
35
+ import type { Store } from "../store";
36
+
37
+ export type FieldIssue = {
38
+ readonly path: string;
39
+ readonly code: string;
40
+ readonly i18nKey: string;
41
+ readonly params?: Readonly<Record<string, unknown>>;
42
+ };
43
+
44
+ // Everything the UI needs to show or retry a failed call. `code` + `httpStatus`
45
+ // are the structured hooks (Toast picks icon/colour, Form-Controller filters
46
+ // field-level failures); `message` is the fallback for logs + generic toasts.
47
+ // `details.fields` is populated for validation errors — other error classes
48
+ // leave it undefined and callers treat the failure as a toast.
49
+ export type DispatcherError = {
50
+ readonly code: string;
51
+ readonly httpStatus: number;
52
+ readonly i18nKey: string;
53
+ readonly i18nParams?: Readonly<Record<string, unknown>>;
54
+ readonly message: string;
55
+ readonly details?: {
56
+ readonly fields?: readonly FieldIssue[];
57
+ } & Record<string, unknown>;
58
+ // Self-service deep-link to docs.kumiko.so/errors/<reason>. Always set on
59
+ // server-side errors (computed from details.reason or code by KumikoError).
60
+ // Optional only because client-synthesized errors (network drop, savable-
61
+ // queue rejection before transport) can't compute a meaningful docs URL.
62
+ readonly docsUrl?: string;
63
+ // Server-assigned id for log correlation; missing on client-synthesized
64
+ // errors (network drop, savable-queue rejection before transport).
65
+ readonly requestId?: string;
66
+ };
67
+
68
+ export type WriteResult<TData = unknown> =
69
+ | { readonly isSuccess: true; readonly data: TData }
70
+ | { readonly isSuccess: false; readonly error: DispatcherError };
71
+
72
+ export type QueryResult<TData = unknown> =
73
+ | { readonly isSuccess: true; readonly data: TData }
74
+ | { readonly isSuccess: false; readonly error: DispatcherError };
75
+
76
+ // Batch returns an array of per-command results plus the index of the first
77
+ // failure (if any). Matches the server's BatchResult so callers can inspect
78
+ // partial progress before the rollback in a failed batch.
79
+ export type BatchResult =
80
+ | { readonly isSuccess: true; readonly results: readonly WriteResult[] }
81
+ | {
82
+ readonly isSuccess: false;
83
+ readonly error: DispatcherError;
84
+ readonly failedIndex: number;
85
+ readonly results: readonly WriteResult[];
86
+ };
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Commands
90
+ // ---------------------------------------------------------------------------
91
+
92
+ // One entry in a batch. `type` is the qualified handler name
93
+ // (`feature:write:entity:action`). `payload` is the handler's input — shape
94
+ // is declared by the feature author's zod schema on the server, so this
95
+ // stays generic here. Nested-write payloads (parent + hasMany children in a
96
+ // single object) travel through as-is; the server expands them.
97
+ export type Command = {
98
+ readonly type: string;
99
+ readonly payload: unknown;
100
+ };
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Status + pending queues
104
+ // ---------------------------------------------------------------------------
105
+
106
+ // "online" — transport reachable, writes/queries go through synchronously.
107
+ // "offline" — transport unreachable; savable queues, live fails immediately.
108
+ // "syncing" — savable's catch-up window after reconnect (queue draining).
109
+ // live-dispatcher never reports "syncing" — nothing to catch up.
110
+ export type DispatcherStatus = "online" | "offline" | "syncing";
111
+
112
+ // What the UI shows as "N changes waiting" badges. Both entries hold enough
113
+ // context for a user-facing list ("Task 'Buy milk' – retry? / discard?");
114
+ // the dispatcher itself drives the retry/sync — this is read-only for
115
+ // rendering.
116
+ export type PendingWrite = {
117
+ readonly id: string;
118
+ readonly type: string;
119
+ readonly payload: unknown;
120
+ // Optimistic snapshot of when the user clicked submit, so the UI can show
121
+ // "3 min ago" without reaching for the internal queue's timestamps.
122
+ readonly queuedAt: string;
123
+ readonly attempts: number;
124
+ readonly lastError?: DispatcherError;
125
+ };
126
+
127
+ export type PendingFile = {
128
+ readonly id: string;
129
+ readonly fileName: string;
130
+ readonly sizeBytes: number;
131
+ readonly queuedAt: string;
132
+ readonly attempts: number;
133
+ readonly progress?: number; // 0..1 when uploading; undefined when queued
134
+ readonly lastError?: DispatcherError;
135
+ };
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Call-site options
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export type WriteOpts = {
142
+ // Client-generated idempotency key — server dedupes and returns the
143
+ // cached result on retry. Required for any write a user can trigger
144
+ // twice (double-click submits, connection-retry in savable).
145
+ readonly requestId?: string;
146
+ // Abort handle — the UI cancels a stale submit when the screen changes
147
+ // or the user presses Escape. Live-dispatcher passes this through to
148
+ // fetch; savable-dispatcher removes the entry from its queue.
149
+ readonly signal?: AbortSignal;
150
+ };
151
+
152
+ export type QueryOpts = {
153
+ readonly signal?: AbortSignal;
154
+ };
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // The contract
158
+ // ---------------------------------------------------------------------------
159
+
160
+ // Every client-side dispatcher implements this interface. Hooks
161
+ // (`useMutation`, `useCommand`) take it via provider/context; features don't
162
+ // know or care which concrete dispatcher they run against. Adding a new
163
+ // dispatcher (e.g. a server-sent dispatcher for SSR prefetch) means
164
+ // implementing this interface — no changes to feature code.
165
+ export type Dispatcher = {
166
+ write<TData = unknown>(
167
+ type: string,
168
+ payload: unknown,
169
+ opts?: WriteOpts,
170
+ ): Promise<WriteResult<TData>>;
171
+
172
+ query<TData = unknown>(
173
+ type: string,
174
+ payload: unknown,
175
+ opts?: QueryOpts,
176
+ ): Promise<QueryResult<TData>>;
177
+
178
+ batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult>;
179
+
180
+ // --- Status ---
181
+
182
+ // Subscribe/Emit-Store für Online/Offline/Syncing-Transitions. Konsumenten
183
+ // greifen direkt mit `useStore(dispatcher.statusStore)` zu — keine eigenen
184
+ // status()/subscribe()-Wrapper, der Store ist die ganze API.
185
+ // `Store` (read-only): UI darf den Status NICHT setzen, das ist
186
+ // Dispatcher-intern. Live-Dispatcher hält intern eine WritableStore-Ref,
187
+ // exportiert aber nur den Read-View.
188
+ readonly statusStore: Store<DispatcherStatus>;
189
+
190
+ // --- Pending queues (only meaningful for savable; live returns []) ---
191
+
192
+ pendingWrites(): readonly PendingWrite[];
193
+ pendingFiles(): readonly PendingFile[];
194
+ };
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createFormController } from "../form-controller";
4
+
5
+ describe("conditional fields — FieldState resolution", () => {
6
+ test("unlisted fields get the default {visible:true, readonly:false, required:false}", () => {
7
+ const form = createFormController({ initial: { title: "hello" } });
8
+ const snap = form.getSnapshot();
9
+
10
+ // No rules declared → snapshot.fields is empty. Renderer treats
11
+ // missing keys as defaults.
12
+ expect(snap.fields).toEqual({});
13
+ });
14
+
15
+ test("static boolean condition resolves once at snapshot time", () => {
16
+ const form = createFormController({
17
+ initial: { title: "" },
18
+ fields: {
19
+ title: { visible: true, readonly: true, required: true },
20
+ },
21
+ });
22
+
23
+ expect(form.getSnapshot().fields["title"]).toEqual({
24
+ visible: true,
25
+ readonly: true,
26
+ required: true,
27
+ });
28
+ });
29
+
30
+ test("predicate condition gets current values", () => {
31
+ const form = createFormController<{ type: "a" | "b"; extra: string }>({
32
+ initial: { type: "a", extra: "" },
33
+ fields: {
34
+ extra: {
35
+ // "extra" is only required when type === "b"
36
+ required: (values) => values.type === "b",
37
+ visible: (values) => values.type === "b",
38
+ },
39
+ },
40
+ });
41
+
42
+ expect(form.getSnapshot().fields["extra"]).toEqual({
43
+ visible: false,
44
+ readonly: false,
45
+ required: false,
46
+ });
47
+
48
+ form.setField("type", "b");
49
+
50
+ expect(form.getSnapshot().fields["extra"]).toEqual({
51
+ visible: true,
52
+ readonly: false,
53
+ required: true,
54
+ });
55
+ });
56
+
57
+ test("predicate reads ctx for cross-cutting conditions (tenant, role)", () => {
58
+ type Ctx = { readonly isAdmin: boolean };
59
+ const form = createFormController<{ title: string }, Ctx>({
60
+ initial: { title: "" },
61
+ fields: {
62
+ title: {
63
+ readonly: (_values, ctx) => !ctx.isAdmin,
64
+ },
65
+ },
66
+ ctx: { isAdmin: false },
67
+ });
68
+
69
+ expect(form.getSnapshot().fields["title"]?.readonly).toBe(true);
70
+
71
+ form.setCtx({ isAdmin: true });
72
+
73
+ expect(form.getSnapshot().fields["title"]?.readonly).toBe(false);
74
+ });
75
+
76
+ test("setCtx: predicate re-reads ctx on the next snapshot (not captured-at-create)", () => {
77
+ // Regression guard: a future "cache predicates for perf" refactor could
78
+ // bind the ctx into the predicate at create-time instead of calling it
79
+ // fresh each buildSnapshot. If that happens, setCtx would fire a
80
+ // notification but the predicate would still see the old ctx. This
81
+ // test asserts the predicate actually reads the new ctx value after
82
+ // setCtx — the notification alone isn't enough.
83
+ type Ctx = { readonly role: "user" | "admin" };
84
+ let lastCtxSeen: string | null = null;
85
+ const form = createFormController<{ title: string }, Ctx>({
86
+ initial: { title: "" },
87
+ fields: {
88
+ title: {
89
+ readonly: (_v, ctx) => {
90
+ lastCtxSeen = ctx.role;
91
+ return ctx.role !== "admin";
92
+ },
93
+ },
94
+ },
95
+ ctx: { role: "user" },
96
+ });
97
+
98
+ // Force a snapshot build so the predicate runs once.
99
+ form.getSnapshot();
100
+ expect(lastCtxSeen).toBe("user");
101
+
102
+ form.setCtx({ role: "admin" });
103
+ form.getSnapshot();
104
+
105
+ // The predicate must have been re-called AND observed the new ctx.
106
+ expect(lastCtxSeen).toBe("admin");
107
+ });
108
+
109
+ test("setCtx produces a new snapshot and notifies listeners", () => {
110
+ const form = createFormController<{ title: string }, { readonly role: string }>({
111
+ initial: { title: "" },
112
+ fields: {
113
+ title: { readonly: (_v, ctx) => ctx.role !== "admin" },
114
+ },
115
+ ctx: { role: "user" },
116
+ });
117
+
118
+ const before = form.getSnapshot();
119
+ form.setCtx({ role: "admin" });
120
+
121
+ expect(form.getSnapshot()).not.toBe(before);
122
+ expect(form.getSnapshot().fields["title"]?.readonly).toBe(false);
123
+ });
124
+
125
+ test("hidden fields are excluded from validate() — required:false for hidden works as expected", () => {
126
+ // A hidden field with required:true on its schema should NOT fail
127
+ // validation. The user isn't shown the field, so they can't satisfy
128
+ // it — forcing them to is a UX footgun.
129
+ const schema = z.object({
130
+ type: z.enum(["a", "b"]),
131
+ extra: z.string().min(1), // required in schema
132
+ });
133
+
134
+ const form = createFormController<{ type: "a" | "b"; extra: string }>({
135
+ initial: { type: "a", extra: "" },
136
+ schema,
137
+ fields: {
138
+ extra: {
139
+ // Hidden when type !== "b"
140
+ visible: (v) => v.type === "b",
141
+ },
142
+ },
143
+ });
144
+
145
+ // type="a" → extra hidden → even with empty `extra`, validate() succeeds.
146
+ expect(form.validate()).toBe(true);
147
+
148
+ form.setField("type", "b");
149
+ // type="b" → extra visible → empty value now fails.
150
+ expect(form.validate()).toBe(false);
151
+ expect(form.getSnapshot().errors["extra"]).toBeDefined();
152
+ });
153
+
154
+ test("snapshot.fields resolves after each setField call (live-reactive)", () => {
155
+ let predicateCalls = 0;
156
+ const form = createFormController<{ type: "a" | "b"; extra: string }>({
157
+ initial: { type: "a", extra: "" },
158
+ fields: {
159
+ extra: {
160
+ visible: (v) => {
161
+ predicateCalls++;
162
+ return v.type === "b";
163
+ },
164
+ },
165
+ },
166
+ });
167
+
168
+ form.getSnapshot(); // build 1
169
+ form.setField("type", "b"); // build 2
170
+ form.getSnapshot(); // still build 2 (identity preserved)
171
+ form.getSnapshot();
172
+
173
+ // Snapshot is cached — predicate only runs on mutator-driven rebuilds,
174
+ // not on every getSnapshot().
175
+ expect(predicateCalls).toBeLessThanOrEqual(3); // initial + 1 mutation + possibly 1 buffer
176
+ });
177
+ });
@@ -0,0 +1,195 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createFormController } from "../form-controller";
3
+
4
+ describe("createFormController — core state machine", () => {
5
+ test("initial state: values === initial, no changes, not dirty, no errors", () => {
6
+ const form = createFormController({ initial: { title: "hello", count: 3 } });
7
+ const snap = form.getSnapshot();
8
+
9
+ expect(snap.values).toEqual({ title: "hello", count: 3 });
10
+ expect(snap.initial).toEqual({ title: "hello", count: 3 });
11
+ expect(snap.changes).toEqual({});
12
+ expect(snap.isDirty).toBe(false);
13
+ expect(snap.isUnchanged).toBe(true);
14
+ expect(snap.errors).toEqual({});
15
+ });
16
+
17
+ test("setField: tracks the single-field change and flips isDirty", () => {
18
+ const form = createFormController({ initial: { title: "hello", count: 3 } });
19
+
20
+ form.setField("title", "world");
21
+ const snap = form.getSnapshot();
22
+
23
+ expect(snap.values.title).toBe("world");
24
+ expect(snap.values.count).toBe(3);
25
+ expect(snap.initial.title).toBe("hello"); // baseline unchanged
26
+ expect(snap.changes).toEqual({ title: "world" });
27
+ expect(snap.isDirty).toBe(true);
28
+ expect(snap.isUnchanged).toBe(false);
29
+ });
30
+
31
+ test("setField to same value is a no-op: no new snapshot, no listener fires", () => {
32
+ const form = createFormController({ initial: { title: "hello" } });
33
+ const before = form.getSnapshot();
34
+ const listener = vi.fn();
35
+ form.subscribe(listener);
36
+
37
+ form.setField("title", "hello");
38
+
39
+ expect(form.getSnapshot()).toBe(before); // identity preserved
40
+ expect(listener).not.toHaveBeenCalled();
41
+ });
42
+
43
+ test("typing back to the initial value clears the change from `changes`", () => {
44
+ const form = createFormController({ initial: { title: "hello" } });
45
+
46
+ form.setField("title", "world");
47
+ expect(form.getSnapshot().changes).toEqual({ title: "world" });
48
+
49
+ form.setField("title", "hello");
50
+ const snap = form.getSnapshot();
51
+ expect(snap.changes).toEqual({});
52
+ expect(snap.isDirty).toBe(false);
53
+ });
54
+
55
+ test("setValues: bulk update fires one notify, one snapshot rebuild", () => {
56
+ const form = createFormController({ initial: { a: 1, b: 2, c: 3 } });
57
+ const listener = vi.fn();
58
+ form.subscribe(listener);
59
+
60
+ form.setValues({ a: 10, b: 20 });
61
+
62
+ expect(form.getSnapshot().changes).toEqual({ a: 10, b: 20 });
63
+ expect(listener).toHaveBeenCalledTimes(1); // not 2
64
+ });
65
+
66
+ test("setValues with no effective changes is a no-op", () => {
67
+ const form = createFormController({ initial: { a: 1, b: 2 } });
68
+ const before = form.getSnapshot();
69
+ const listener = vi.fn();
70
+ form.subscribe(listener);
71
+
72
+ form.setValues({ a: 1 });
73
+
74
+ expect(form.getSnapshot()).toBe(before);
75
+ expect(listener).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test("subscribe/unsubscribe: listener stops firing after unsubscribe", () => {
79
+ const form = createFormController({ initial: { title: "hello" } });
80
+ const listener = vi.fn();
81
+ const unsubscribe = form.subscribe(listener);
82
+
83
+ form.setField("title", "world");
84
+ expect(listener).toHaveBeenCalledTimes(1);
85
+
86
+ unsubscribe();
87
+ form.setField("title", "again");
88
+ expect(listener).toHaveBeenCalledTimes(1); // still 1
89
+ });
90
+
91
+ test("getSnapshot returns stable reference between mutators", () => {
92
+ const form = createFormController({ initial: { title: "hello" } });
93
+
94
+ const a = form.getSnapshot();
95
+ const b = form.getSnapshot();
96
+ expect(a).toBe(b); // identity compare — required for useSyncExternalStore
97
+
98
+ form.setField("title", "world");
99
+ const c = form.getSnapshot();
100
+ expect(c).not.toBe(a); // mutation produced a new snapshot
101
+ });
102
+
103
+ test("snapshot is frozen — mutating it throws in strict mode", () => {
104
+ const form = createFormController({ initial: { title: "hello" } });
105
+ const snap = form.getSnapshot();
106
+
107
+ expect(() => {
108
+ (snap as unknown as { values: unknown }).values = {};
109
+ }).toThrow();
110
+ });
111
+
112
+ test("reset: restores values to initial and clears errors", () => {
113
+ const form = createFormController({ initial: { title: "hello", count: 3 } });
114
+ form.setField("title", "world");
115
+ form.setErrors({ title: [{ path: "title", code: "bad", i18nKey: "x" }] });
116
+
117
+ form.reset();
118
+ const snap = form.getSnapshot();
119
+
120
+ expect(snap.values).toEqual({ title: "hello", count: 3 });
121
+ expect(snap.isDirty).toBe(false);
122
+ expect(snap.errors).toEqual({});
123
+ });
124
+
125
+ test("reset is a no-op when already clean and error-free", () => {
126
+ const form = createFormController({ initial: { title: "hello" } });
127
+ const before = form.getSnapshot();
128
+ const listener = vi.fn();
129
+ form.subscribe(listener);
130
+
131
+ form.reset();
132
+
133
+ expect(form.getSnapshot()).toBe(before);
134
+ expect(listener).not.toHaveBeenCalled();
135
+ });
136
+
137
+ test("rebase: current values become the new baseline, changes collapse to {}", () => {
138
+ const form = createFormController({ initial: { title: "hello" } });
139
+ form.setField("title", "world");
140
+ expect(form.getSnapshot().changes).toEqual({ title: "world" });
141
+
142
+ form.rebase();
143
+ const snap = form.getSnapshot();
144
+
145
+ expect(snap.values.title).toBe("world");
146
+ expect(snap.initial.title).toBe("world"); // baseline updated
147
+ expect(snap.changes).toEqual({});
148
+ expect(snap.isDirty).toBe(false);
149
+ });
150
+
151
+ test("setErrors + clearErrors(path): targeted removal leaves other errors alone", () => {
152
+ const form = createFormController({ initial: { a: "", b: "" } });
153
+ form.setErrors({
154
+ a: [{ path: "a", code: "required", i18nKey: "x" }],
155
+ b: [{ path: "b", code: "required", i18nKey: "x" }],
156
+ });
157
+
158
+ form.clearErrors("a");
159
+ const snap = form.getSnapshot();
160
+
161
+ expect(snap.errors["a"]).toBeUndefined();
162
+ expect(snap.errors["b"]).toBeDefined();
163
+ });
164
+
165
+ test("clearErrors() without path wipes everything", () => {
166
+ const form = createFormController({ initial: { a: "" } });
167
+ form.setErrors({ a: [{ path: "a", code: "required", i18nKey: "x" }] });
168
+
169
+ form.clearErrors();
170
+
171
+ expect(form.getSnapshot().errors).toEqual({});
172
+ });
173
+
174
+ test("mutating the input object after create does not bleed into controller state", () => {
175
+ const initial = { title: "hello" };
176
+ const form = createFormController({ initial });
177
+
178
+ initial.title = "mutated-from-outside";
179
+
180
+ expect(form.getSnapshot().initial.title).toBe("hello");
181
+ expect(form.getSnapshot().values.title).toBe("hello");
182
+ });
183
+
184
+ test("deleted field via setValues({ key: undefined }) still counts as a change", () => {
185
+ // Covers the both-sides iteration in valuesDiff — a field cleared from
186
+ // the current values still diverges from initial and shows up in changes.
187
+ const form = createFormController({ initial: { title: "hello" } });
188
+ form.setValues({ title: undefined });
189
+
190
+ const snap = form.getSnapshot();
191
+ expect(snap.isDirty).toBe(true);
192
+ expect("title" in snap.changes).toBe(true);
193
+ expect(snap.changes["title"]).toBeUndefined();
194
+ });
195
+ });