@hadoku/prefs-client 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 WolffM
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # @wolffm/prefs-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Client SDK for the hadoku unified preferences store. Wraps `hadoku.me/prefs/api/v1/*` with localStorage caching, debounced writes, Zod-validated reads, and a React `usePrefs()` hook.
4
+
5
+ See [`docs/planning/unified-prefs-2026-05-20.md`](https://github.com/WolffM/hadoku_site/blob/main/docs/planning/unified-prefs-2026-05-20.md) for the architectural background.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @wolffm/prefs-client zod
11
+ # React consumers also:
12
+ pnpm add react
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { z } from 'zod';
19
+ import { createPrefsClient } from '@wolffm/prefs-client';
20
+ import { usePrefs } from '@wolffm/prefs-client/react';
21
+
22
+ const TaskPrefsSchema = z.object({
23
+ theme: z.string().optional(),
24
+ themeMode: z.enum(['simple', 'advanced']).optional(),
25
+ alwaysVerticalLayout: z.boolean().optional(),
26
+ });
27
+
28
+ export const taskPrefs = createPrefsClient({
29
+ appId: 'task',
30
+ schema: TaskPrefsSchema,
31
+ defaults: { theme: 'light', themeMode: 'simple' },
32
+ migrations: [
33
+ (blob) => {
34
+ if ('simpleMode' in blob) {
35
+ blob.themeMode = blob.simpleMode ? 'simple' : 'advanced';
36
+ delete blob.simpleMode;
37
+ }
38
+ return blob;
39
+ },
40
+ ],
41
+ bootstrapToSessionStorage: { theme: 'hadoku-theme' },
42
+ });
43
+
44
+ function Settings() {
45
+ const { prefs, save, loading } = usePrefs(taskPrefs);
46
+ if (loading || !prefs) return null;
47
+ return (
48
+ <ThemeToggle
49
+ value={prefs.theme}
50
+ onChange={(t) => save({ theme: t }, { scope: 'device' })}
51
+ />
52
+ );
53
+ }
54
+ ```
55
+
56
+ ## Key behaviours
57
+
58
+ - **Cache**: localStorage at `prefs-cache:{userId}:{appId}`. Stale-while-revalidate.
59
+ - **Writes**: 1s-debounced PUT. Multiple `save()` calls within the window coalesce into one network call.
60
+ - **Conflicts**: API-Always-Wins. On a 409 the SDK adopts the server blob and re-renders; the local patch is discarded.
61
+ - **Cross-tab**: BroadcastChannel keyed by `prefs:{userId}:{appId}` propagates saves between tabs without a server roundtrip.
62
+ - **Anon → authed**: call `client.promoteAnonPrefsToUser()` after sign-in. If the server already has user-scope prefs (signed in on another device first), the server wins.
63
+ - **Dirty-field protection**: a background refresh (or an incoming cross-tab broadcast) never overwrites a field with a save still pending/in-flight — it merges the server/remote blob with your unconfirmed local value instead. Otherwise a refresh landing between an optimistic write and its debounced PUT would visibly revert the change.
64
+ - **FOUC bootstrap**: `bootstrapToSessionStorage` mirrors specific fields to sessionStorage so the inline `<head>` script can apply theme before React mounts. Mirroring only happens on an explicit local `save()` by default — never on read/hydrate/refresh/broadcast-receive — so an app embedded under a host that owns the same sessionStorage key can't clobber it just by booting. Pass `mirrorBootstrapOnRead: true` to restore the old every-read mirroring for a key nothing else writes to.
65
+
66
+ ## Capacitor (native APK)
67
+
68
+ Storage is **auto-detected**: in a native Capacitor webview the SDK reads
69
+ the `Capacitor.Plugins.Preferences` global and uses it for the cache; in a
70
+ browser it uses localStorage. No configuration, no import, and **no
71
+ `@capacitor/preferences` dependency added to web bundles** — the detection
72
+ is a runtime global read, so web-only consumers are unaffected.
73
+
74
+ Requirements for the native side:
75
+
76
+ - The host APK must have `@capacitor/preferences` installed (so Capacitor
77
+ registers the plugin global). The SDK does not bundle or import it.
78
+ - The APK mints its own `hadoku_device_id` in native storage, separate from
79
+ the mobile browser's — so the native app and mobile Safari are two
80
+ distinct devices by design.
81
+
82
+ You can still override with an explicit adapter (or force one in tests) via
83
+ `createPrefsClient({ storage })`. `detectStorage()` and `capacitorStorage()`
84
+ are exported if you want to resolve the adapter yourself.
85
+
86
+ ## Header-mode (service-to-service, no cookie)
87
+
88
+ Pass `userKey` when there's no `hadoku_session` cookie to ride (e.g. a CLI
89
+ or a native app that authenticates with a raw key instead of the cookie):
90
+
91
+ ```ts
92
+ const client = createPrefsClient({
93
+ appId: 'task-mobile',
94
+ schema: TaskPrefsSchema,
95
+ userKey: process.env.HADOKU_API_KEY,
96
+ // storage auto-detects Capacitor; pass an explicit adapter only to override
97
+ });
98
+ ```
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Game stats — the wire contract, and the rules both ends must agree on.
3
+ *
4
+ * Pure: no fetch, no DOM. prefs-api imports this file directly so the ingest
5
+ * validates against the very types the client is built from, and the two can
6
+ * never be hand-synced out of step the way watchparty-stats-api's 28 columns
7
+ * were. The client half lives in ./games.ts.
8
+ *
9
+ * Design: hadoku_site docs/planning/game-stats-2026-10-09.md
10
+ */
11
+ /** A categorical fact. An array is a SET — a breakdown counts each element. */
12
+ type StatAttr = string | number | boolean | string[];
13
+ type StatAttrs = Record<string, StatAttr>;
14
+ /** A numeric fact. Summed across matches, and maxed, by every aggregate read. */
15
+ type StatCounters = Record<string, number>;
16
+ type SeatKind = 'user' | 'guest' | 'bot';
17
+ type SeatOutcome = 'win' | 'loss' | 'draw';
18
+ declare const GAME_ID_PATTERN: RegExp;
19
+ declare const STAT_KEY_PATTERN: RegExp;
20
+ declare const DEFAULT_MODE = "default";
21
+ declare const STARTING_RATING = 1500;
22
+ /** A game server's own id for a match — a UUID fits, so does a ULID. */
23
+ declare const MATCH_ID_PATTERN: RegExp;
24
+ /** Ingest bounds. Generous for a game, tight for a payload someone abuses. */
25
+ declare const STAT_LIMITS: {
26
+ readonly seats: 16;
27
+ readonly keysPerObject: 48;
28
+ readonly stringLength: 200;
29
+ readonly arrayLength: 64;
30
+ };
31
+ interface MatchSeatReport {
32
+ seat: number;
33
+ kind: SeatKind;
34
+ /** The player's own key, as the edge stamped it on their requests. Required
35
+ * for `kind: 'user'`, absent otherwise. Resolved and discarded by the API. */
36
+ userKey?: string;
37
+ /** What the room called this player. A snapshot for history, never a key. */
38
+ displayName?: string;
39
+ /** 1 = winner. Equal placements tie; all-equal is a draw. */
40
+ placement: number;
41
+ attrs?: StatAttrs;
42
+ counters?: StatCounters;
43
+ }
44
+ interface MatchReport {
45
+ /** Minted by the game server. Re-posting the same id is a no-op. */
46
+ matchId: string;
47
+ /** Rating pool; omit for a game with one competitive shape. */
48
+ mode?: string;
49
+ roomId?: string;
50
+ startedAt?: number;
51
+ endedAt: number;
52
+ /** false records the match — history, profile, breakdowns — without moving anyone's rating or counting it on the leaderboard. Default true. */
53
+ rated?: boolean;
54
+ attrs?: StatAttrs;
55
+ counters?: StatCounters;
56
+ seats: MatchSeatReport[];
57
+ }
58
+ interface RecordMatchResult {
59
+ matchId: string;
60
+ /** true when this matchId was already recorded and nothing changed. */
61
+ duplicate: boolean;
62
+ /** New rating per seat number, for seats whose rating moved. */
63
+ ratings: Record<number, number>;
64
+ }
65
+ interface SeatView {
66
+ seat: number;
67
+ kind: SeatKind;
68
+ /** The registry name for a user seat (current, so it follows a rename);
69
+ * the reported display name otherwise. */
70
+ name: string | null;
71
+ /** true on the caller's own seat. */
72
+ isMe: boolean;
73
+ placement: number;
74
+ outcome: SeatOutcome;
75
+ ratingBefore: number | null;
76
+ ratingAfter: number | null;
77
+ attrs: StatAttrs;
78
+ counters: StatCounters;
79
+ }
80
+ interface MatchView {
81
+ matchId: string;
82
+ gameId: string;
83
+ mode: string;
84
+ startedAt: number | null;
85
+ endedAt: number;
86
+ rated: boolean;
87
+ attrs: StatAttrs;
88
+ counters: StatCounters;
89
+ seats: SeatView[];
90
+ }
91
+ interface MatchPage {
92
+ matches: MatchView[];
93
+ /** Pass back as `before` for the next page; null on the last one. */
94
+ nextBefore: number | null;
95
+ }
96
+ interface OutcomeTotals {
97
+ games: number;
98
+ wins: number;
99
+ losses: number;
100
+ draws: number;
101
+ }
102
+ interface CounterTotals {
103
+ sum: number;
104
+ max: number;
105
+ }
106
+ interface ProfileView {
107
+ name: string;
108
+ isMe: boolean;
109
+ totals: OutcomeTotals;
110
+ ratings: {
111
+ mode: string;
112
+ rating: number;
113
+ games: number;
114
+ }[];
115
+ counters: Record<string, CounterTotals>;
116
+ lastPlayedAt: number | null;
117
+ }
118
+ /** `rating`, an outcome total, `winrate`, or `counter:<key>` (summed). */
119
+ type LeaderboardBy = 'rating' | 'games' | 'wins' | 'winrate' | `counter:${string}`;
120
+ interface LeaderboardRow {
121
+ rank: number;
122
+ name: string;
123
+ isMe: boolean;
124
+ value: number;
125
+ totals: OutcomeTotals;
126
+ }
127
+ interface BreakdownRow extends OutcomeTotals {
128
+ value: string;
129
+ /** The seat attrs a read asked to carry (`with`), off the group's most recent seat. Absent when it asked for none. */
130
+ attrs?: StatAttrs;
131
+ }
132
+ /** Win/loss/draw from placements, the one place that rule is written. */
133
+ declare function outcomesFor(placements: readonly number[]): SeatOutcome[];
134
+ declare function winRate(t: OutcomeTotals): number;
135
+
136
+ interface GameStatsClientOptions {
137
+ gameId: string;
138
+ /** The prefs mount (`https://hadoku.me/prefs` by default), without `/api/v1`. */
139
+ baseUrl?: string;
140
+ fetch?: typeof fetch;
141
+ }
142
+ declare class GameStatsError extends Error {
143
+ readonly status: number;
144
+ constructor(status: number, message: string);
145
+ }
146
+ /**
147
+ * What a read is narrowed to beyond its player and mode. Both default to off,
148
+ * so a read that names neither sees every match.
149
+ */
150
+ interface StatsFilter {
151
+ /** Only the matches that ranked: a game reports one unrated when a bot, a guest or a test account sat in it. */
152
+ rated?: boolean;
153
+ /** Only the matches whose scalar match attr is one of the values: `{ format: ['standard', 'modern'] }`. */
154
+ match?: Readonly<Record<string, readonly string[]>>;
155
+ }
156
+ /** `player` is a registry name; omit it for the caller. */
157
+ interface PlayerScope extends StatsFilter {
158
+ player?: string;
159
+ mode?: string;
160
+ }
161
+ declare function createGameStatsClient(opts: GameStatsClientOptions): {
162
+ profile: (s?: PlayerScope) => Promise<ProfileView>;
163
+ matches: (s?: PlayerScope & {
164
+ limit?: number;
165
+ before?: number;
166
+ }) => Promise<MatchPage>;
167
+ match: (matchId: string) => Promise<MatchView>;
168
+ leaderboard: (s?: {
169
+ by?: LeaderboardBy;
170
+ mode?: string;
171
+ limit?: number;
172
+ }) => Promise<{
173
+ by: LeaderboardBy;
174
+ rows: LeaderboardRow[];
175
+ }>;
176
+ /**
177
+ * Outcome totals grouped by one attr. `player: '*'` spans everyone.
178
+ * `with` names seat attrs each row carries, off its most recent seat.
179
+ */
180
+ breakdown: (attr: string, { with: withAttrs, ...s }?: PlayerScope & {
181
+ with?: readonly string[];
182
+ }) => Promise<{
183
+ attr: string;
184
+ rows: BreakdownRow[];
185
+ }>;
186
+ };
187
+ type GameStatsClient = ReturnType<typeof createGameStatsClient>;
188
+ interface RecordMatchOptions extends GameStatsClientOptions {
189
+ /** The game server's own service-tier key. */
190
+ serviceKey: string;
191
+ }
192
+ /** Server-side: report one finished match. Safe to retry with the same matchId. */
193
+ declare function recordMatch(opts: RecordMatchOptions, report: MatchReport): Promise<RecordMatchResult>;
194
+
195
+ export { type BreakdownRow, type CounterTotals, DEFAULT_MODE, GAME_ID_PATTERN, type GameStatsClient, type GameStatsClientOptions, GameStatsError, type LeaderboardBy, type LeaderboardRow, MATCH_ID_PATTERN, type MatchPage, type MatchReport, type MatchSeatReport, type MatchView, type OutcomeTotals, type PlayerScope, type ProfileView, type RecordMatchOptions, type RecordMatchResult, STARTING_RATING, STAT_KEY_PATTERN, STAT_LIMITS, type SeatKind, type SeatOutcome, type SeatView, type StatAttr, type StatAttrs, type StatCounters, type StatsFilter, createGameStatsClient, outcomesFor, recordMatch, winRate };
package/dist/games.js ADDED
@@ -0,0 +1,99 @@
1
+ // src/constants.ts
2
+ var PREFS_API_BASE = "https://hadoku.me/prefs";
3
+
4
+ // src/games-contract.ts
5
+ var GAME_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
6
+ var STAT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/;
7
+ var DEFAULT_MODE = "default";
8
+ var STARTING_RATING = 1500;
9
+ var MATCH_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
10
+ var STAT_LIMITS = {
11
+ seats: 16,
12
+ keysPerObject: 48,
13
+ stringLength: 200,
14
+ arrayLength: 64
15
+ };
16
+ function outcomesFor(placements) {
17
+ const best = Math.min(...placements);
18
+ const allTied = placements.every((p) => p === best);
19
+ return placements.map((p) => allTied ? "draw" : p === best ? "win" : "loss");
20
+ }
21
+ function winRate(t) {
22
+ return t.games === 0 ? 0 : t.wins / t.games;
23
+ }
24
+
25
+ // src/games.ts
26
+ var GameStatsError = class extends Error {
27
+ constructor(status, message) {
28
+ super(message);
29
+ this.status = status;
30
+ this.name = "GameStatsError";
31
+ }
32
+ status;
33
+ };
34
+ function gameBase(opts) {
35
+ if (!GAME_ID_PATTERN.test(opts.gameId)) throw new Error(`invalid gameId: ${opts.gameId}`);
36
+ return `${opts.baseUrl ?? PREFS_API_BASE}/api/v1/_games/${opts.gameId}`;
37
+ }
38
+ async function readJson(res) {
39
+ if (!res.ok) {
40
+ const body = await res.json().catch(() => null);
41
+ throw new GameStatsError(res.status, body?.message ?? `HTTP ${res.status}`);
42
+ }
43
+ return await res.json();
44
+ }
45
+ function query({
46
+ match,
47
+ ...params
48
+ }) {
49
+ const q = new URLSearchParams();
50
+ for (const [k, v] of Object.entries(params)) {
51
+ if (v === void 0 || v === false) continue;
52
+ q.set(k, v === true ? "1" : String(v));
53
+ }
54
+ for (const [k, values] of Object.entries(match ?? {})) {
55
+ for (const v of values) q.append(`match.${k}`, v);
56
+ }
57
+ const s = q.toString();
58
+ return s ? `?${s}` : "";
59
+ }
60
+ function createGameStatsClient(opts) {
61
+ const base = gameBase(opts);
62
+ const f = opts.fetch ?? fetch;
63
+ const get = (path) => f(`${base}${path}`, { credentials: "include" }).then(readJson);
64
+ return {
65
+ profile: (s = {}) => get(`/profile${query({ ...s })}`),
66
+ matches: (s = {}) => get(`/matches${query({ ...s })}`),
67
+ match: (matchId) => get(`/matches/${encodeURIComponent(matchId)}`),
68
+ leaderboard: (s = {}) => get(`/leaderboard${query({ ...s })}`),
69
+ /**
70
+ * Outcome totals grouped by one attr. `player: '*'` spans everyone.
71
+ * `with` names seat attrs each row carries, off its most recent seat.
72
+ */
73
+ breakdown: (attr, { with: withAttrs, ...s } = {}) => get(
74
+ `/breakdown${query({ attr, ...s, with: withAttrs?.length ? withAttrs.join(",") : void 0 })}`
75
+ )
76
+ };
77
+ }
78
+ async function recordMatch(opts, report) {
79
+ const f = opts.fetch ?? fetch;
80
+ const res = await f(`${gameBase(opts)}/matches`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json", "X-User-Key": opts.serviceKey },
83
+ body: JSON.stringify(report)
84
+ });
85
+ return readJson(res);
86
+ }
87
+ export {
88
+ DEFAULT_MODE,
89
+ GAME_ID_PATTERN,
90
+ GameStatsError,
91
+ MATCH_ID_PATTERN,
92
+ STARTING_RATING,
93
+ STAT_KEY_PATTERN,
94
+ STAT_LIMITS,
95
+ createGameStatsClient,
96
+ outcomesFor,
97
+ recordMatch,
98
+ winRate
99
+ };
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod';
2
+ import { P as PrefsClientOptions, a as PrefsClient, A as AsyncStorage } from './types-DCldC4WA.js';
3
+ export { b as PrefsMigration, c as PrefsRow, S as SaveOptions, d as Scope, e as ServerReadResult, f as capacitorStorage, g as detectStorage, l as localStorageAdapter, m as memoryStorage, s as sessionStorageWrite } from './types-DCldC4WA.js';
4
+
5
+ /**
6
+ * createPrefsClient — vanilla (no React) client for the prefs-api.
7
+ *
8
+ * Architecture, top-down:
9
+ *
10
+ * read()
11
+ * ├─ if cache present → return cached blob synchronously-ish + kick off
12
+ * │ a background refresh (return-stale-while-revalidate)
13
+ * └─ if cache empty → fetch from server, populate cache, return
14
+ *
15
+ * save(patch)
16
+ * ├─ merge patch into in-memory current blob
17
+ * ├─ update cache + notify listeners synchronously (optimistic UI)
18
+ * ├─ debounce 1s, then PUT
19
+ * └─ on 409: adopt server blob from response, overwrite cache, notify
20
+ * (API-Always-Wins per docs/planning/unified-prefs-2026-05-20.md)
21
+ *
22
+ * subscribe(listener)
23
+ * ├─ stash listener in a Set
24
+ * ├─ also wire BroadcastChannel('prefs:{userId}:{appId}') so cross-tab
25
+ * │ saves propagate without a server roundtrip
26
+ * └─ unsubscribe returns a fn that clears both
27
+ *
28
+ * The client is per-app: one createPrefsClient call per app schema. Sharing
29
+ * the same id across apps would clobber caches.
30
+ */
31
+
32
+ declare function createPrefsClient<S extends z.ZodType>(opts: PrefsClientOptions<S>): PrefsClient<z.infer<S>>;
33
+
34
+ /**
35
+ * Get-or-mint the per-browser device id.
36
+ *
37
+ * Canonical localStorage key: `hadoku_device_id`. Shared across all
38
+ * apps on this browser — every microfrontend gets the same id so prefs
39
+ * scoped to "this device" reflect the user's actual physical device,
40
+ * not the URL they happened to be on when they first set the value.
41
+ *
42
+ * Capacitor APK is a separate "device" by design: the @capacitor/preferences
43
+ * store is isolated from the browser's localStorage, so the APK mints its
44
+ * own id on first launch and never sees the web id. The user can theme
45
+ * the two differently, which is the desired UX.
46
+ *
47
+ * The id is opaque and never sent to the server EXCEPT as the X-Device-Id
48
+ * header on prefs-api requests. It's not a secret — losing it just means
49
+ * the device looks "new" to the prefs store (no security consequence).
50
+ */
51
+
52
+ declare const DEVICE_ID_KEY = "hadoku_device_id";
53
+ /**
54
+ * Read the device id; mint and persist one if absent. The async signature
55
+ * lets us swap in @capacitor/preferences storage transparently.
56
+ */
57
+ declare function getDeviceId(storage?: AsyncStorage): Promise<string>;
58
+
59
+ /**
60
+ * How a cache scope was derived — i.e. how much this identity can be trusted
61
+ * to be distinct from the last person who used this browser.
62
+ *
63
+ * 'user' stable registry userId. Distinct per user, stable across logins.
64
+ * 'session' signed in, but the server gave no stable id (a registry row
65
+ * predating lazy userId minting). Distinct per user; NOT stable
66
+ * across logins, so it costs a cache miss after re-login and
67
+ * re-reads from the server, which is authoritative anyway.
68
+ * 'anon' no session at all. The shared row — correct here, since
69
+ * anonymous visitors are indistinguishable by definition.
70
+ * 'none' signed in and unidentifiable (no userId AND no sessionId).
71
+ * Nothing may be persisted: there is no value that is safe to
72
+ * reuse, and the shared row is exactly the wrong answer.
73
+ */
74
+ type IdentityScope = 'user' | 'session' | 'anon' | 'none';
75
+ interface WhoamiResult {
76
+ userId: string;
77
+ userType: 'admin' | 'wife' | 'service' | 'friend' | 'public';
78
+ name?: string | null;
79
+ /**
80
+ * How `userId` was derived. Callers that PERSIST anything keyed by
81
+ * `userId` must check this: 'none' means do not persist at all, and
82
+ * 'anon' means the key is shared with every other browser user.
83
+ */
84
+ scope: IdentityScope;
85
+ }
86
+ interface WhoamiOpts {
87
+ /** Override the base URL for testing. Default: edge-router origin. */
88
+ whoamiUrl?: string;
89
+ /** Header-mode auth — sent as X-User-Key. Browser path can leave undefined. */
90
+ userKey?: string;
91
+ }
92
+ /** Sentinel userId for callers without a recognised session/key. */
93
+ declare const ANON_USER_ID = "anon";
94
+ declare function whoami(opts?: WhoamiOpts): Promise<WhoamiResult>;
95
+
96
+ /**
97
+ * Build-time constants. Kept in their own module so the React entry can
98
+ * import them without dragging in the rest of the SDK surface.
99
+ *
100
+ * PREFS_API_BASE points at the hadoku edge-router /prefs prefix. Apps
101
+ * that want to test against a non-prod environment pass `apiBase` to
102
+ * createPrefsClient — this default is for the published-package consumer
103
+ * who just wants it to work against prod.
104
+ *
105
+ * Lint-rule note: workers in this repo are required to use the edge-routes
106
+ * constants from @wolffm/worker-utils. This package is the consumer-facing
107
+ * SDK published to GitHub Packages and CANNOT depend on worker-utils (which
108
+ * is itself a workspace dep). The hardcoded URL is the contract with the
109
+ * consuming app, so the rule is disabled here.
110
+ */
111
+ declare const PREFS_API_BASE = "https://hadoku.me/prefs";
112
+ /** localStorage cache key prefix. Full key: `prefs-cache:{userId}:{appId}`. */
113
+ declare const CACHE_KEY_PREFIX = "prefs-cache";
114
+ /** Debounce window for PUTs. Matches watchparty's pattern (1s coalesce). */
115
+ declare const SAVE_DEBOUNCE_MS = 1000;
116
+
117
+ export { ANON_USER_ID, AsyncStorage, CACHE_KEY_PREFIX, DEVICE_ID_KEY, type IdentityScope, PREFS_API_BASE, PrefsClient, PrefsClientOptions, SAVE_DEBOUNCE_MS, type WhoamiResult, createPrefsClient, getDeviceId, whoami };
package/dist/index.js ADDED
@@ -0,0 +1,527 @@
1
+ // src/constants.ts
2
+ var PREFS_API_BASE = "https://hadoku.me/prefs";
3
+ var CACHE_KEY_PREFIX = "prefs-cache";
4
+ var SAVE_DEBOUNCE_MS = 1e3;
5
+
6
+ // src/storage.ts
7
+ var localStorageAdapter = {
8
+ get(key) {
9
+ if (typeof window === "undefined") return Promise.resolve(null);
10
+ try {
11
+ return Promise.resolve(window.localStorage.getItem(key));
12
+ } catch {
13
+ return Promise.resolve(null);
14
+ }
15
+ },
16
+ set(key, value) {
17
+ if (typeof window === "undefined") return Promise.resolve();
18
+ try {
19
+ window.localStorage.setItem(key, value);
20
+ } catch {
21
+ }
22
+ return Promise.resolve();
23
+ },
24
+ remove(key) {
25
+ if (typeof window === "undefined") return Promise.resolve();
26
+ try {
27
+ window.localStorage.removeItem(key);
28
+ } catch {
29
+ }
30
+ return Promise.resolve();
31
+ }
32
+ };
33
+ var memoryStorage = () => {
34
+ const store = /* @__PURE__ */ new Map();
35
+ return {
36
+ get(key) {
37
+ return Promise.resolve(store.get(key) ?? null);
38
+ },
39
+ set(key, value) {
40
+ store.set(key, value);
41
+ return Promise.resolve();
42
+ },
43
+ remove(key) {
44
+ store.delete(key);
45
+ return Promise.resolve();
46
+ }
47
+ };
48
+ };
49
+ var sessionStorageWrite = (key, value) => {
50
+ if (typeof window === "undefined") return;
51
+ try {
52
+ window.sessionStorage.setItem(key, value);
53
+ } catch {
54
+ }
55
+ };
56
+ function readCapacitorGlobal() {
57
+ return globalThis.Capacitor;
58
+ }
59
+ function capacitorStorage() {
60
+ const cap = readCapacitorGlobal();
61
+ if (!cap?.isNativePlatform?.()) return null;
62
+ const prefs = cap.Plugins?.Preferences;
63
+ if (!prefs) return null;
64
+ return {
65
+ async get(key) {
66
+ const { value } = await prefs.get({ key });
67
+ return value ?? null;
68
+ },
69
+ async set(key, value) {
70
+ await prefs.set({ key, value });
71
+ },
72
+ async remove(key) {
73
+ await prefs.remove({ key });
74
+ }
75
+ };
76
+ }
77
+ function detectStorage() {
78
+ return capacitorStorage() ?? localStorageAdapter;
79
+ }
80
+
81
+ // src/get-device-id.ts
82
+ var DEVICE_ID_KEY = "hadoku_device_id";
83
+ async function getDeviceId(storage = localStorageAdapter) {
84
+ const existing = await storage.get(DEVICE_ID_KEY);
85
+ if (existing && isUuid(existing)) {
86
+ return existing;
87
+ }
88
+ const fresh = mintUuid();
89
+ await storage.set(DEVICE_ID_KEY, fresh);
90
+ return fresh;
91
+ }
92
+ function mintUuid() {
93
+ const c = globalThis.crypto;
94
+ if (c?.randomUUID) {
95
+ return c.randomUUID();
96
+ }
97
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
98
+ const r = Math.random() * 16 | 0;
99
+ const v = ch === "x" ? r : r & 3 | 8;
100
+ return v.toString(16);
101
+ });
102
+ }
103
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
104
+ function isUuid(s) {
105
+ return UUID_RE.test(s);
106
+ }
107
+
108
+ // src/whoami.ts
109
+ var ANON_USER_ID = "anon";
110
+ var WHOAMI_GLOBAL = "__hadokuWhoami";
111
+ var SESSION_SCOPE_PREFIX = "sess:";
112
+ var UNSCOPED_ID = "unscoped";
113
+ function adaptWhoami(body) {
114
+ if (!body || body.valid === false) {
115
+ return { userId: ANON_USER_ID, userType: "public", scope: "anon" };
116
+ }
117
+ const userType = body.userType === "admin" || body.userType === "wife" || body.userType === "service" || body.userType === "friend" || body.userType === "public" ? body.userType : "public";
118
+ const name = typeof body.name === "string" ? body.name : null;
119
+ if (typeof body.userId === "string" && body.userId) {
120
+ return { userId: body.userId, userType, name, scope: "user" };
121
+ }
122
+ if (typeof body.sessionId === "string" && body.sessionId) {
123
+ return {
124
+ userId: `${SESSION_SCOPE_PREFIX}${body.sessionId}`,
125
+ userType,
126
+ name,
127
+ scope: "session"
128
+ };
129
+ }
130
+ return { userId: UNSCOPED_ID, userType, name, scope: "none" };
131
+ }
132
+ async function fetchWhoamiRaw(url, userKey) {
133
+ const headers = {};
134
+ if (userKey) {
135
+ headers["X-User-Key"] = userKey;
136
+ }
137
+ try {
138
+ const resp = await fetch(url, {
139
+ method: "GET",
140
+ headers,
141
+ credentials: "include",
142
+ cache: "no-store"
143
+ });
144
+ if (!resp.ok) return null;
145
+ return await resp.json();
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ function defaultWhoamiUrl() {
151
+ const loc = globalThis.location;
152
+ if (loc && (loc.protocol === "http:" || loc.protocol === "https:")) return "/session/whoami";
153
+ return `${PREFS_API_BASE.replace(/\/prefs$/, "")}/session/whoami`;
154
+ }
155
+ async function whoami(opts = {}) {
156
+ const url = opts.whoamiUrl ?? defaultWhoamiUrl();
157
+ if (opts.userKey) {
158
+ return adaptWhoami(await fetchWhoamiRaw(url, opts.userKey));
159
+ }
160
+ const g = globalThis;
161
+ let shared = g[WHOAMI_GLOBAL];
162
+ if (!shared) {
163
+ shared = fetchWhoamiRaw(url);
164
+ g[WHOAMI_GLOBAL] = shared;
165
+ }
166
+ try {
167
+ return adaptWhoami(await shared);
168
+ } catch {
169
+ return { userId: ANON_USER_ID, userType: "public", scope: "anon" };
170
+ }
171
+ }
172
+
173
+ // src/client.ts
174
+ function createPrefsClient(opts) {
175
+ const apiBase = opts.apiBase ?? PREFS_API_BASE;
176
+ const mirrorOnRead = opts.mirrorBootstrapOnRead ?? false;
177
+ const storage = opts.storage ?? detectStorage();
178
+ const listeners = /* @__PURE__ */ new Set();
179
+ const migrations = opts.migrations ?? [];
180
+ const defaults = opts.defaults ?? {};
181
+ const bootstrapMap = opts.bootstrapToSessionStorage ?? {};
182
+ let userId = null;
183
+ let identityScope = "anon";
184
+ let wroteWhileAnon = false;
185
+ let deviceId = opts.deviceId ?? null;
186
+ let currentBlob = null;
187
+ let userVersion = 0;
188
+ let deviceVersion = 0;
189
+ let pendingPatch = {};
190
+ let pendingScope = "user";
191
+ const dirtyFields = /* @__PURE__ */ new Set();
192
+ let saveTimer = null;
193
+ let savePromise = null;
194
+ let initPromise = null;
195
+ let bcast = null;
196
+ let destroyed = false;
197
+ const broadcastId = `${Math.random().toString(36).slice(2)}-${Date.now()}`;
198
+ function ensureInit() {
199
+ if (initPromise) return initPromise;
200
+ initPromise = (async () => {
201
+ const who = await whoami({
202
+ userKey: opts.userKey,
203
+ // An EXPLICIT apiBase (a non-prod environment) names where identity lives
204
+ // too. With none, leave it to whoami()'s default — same-origin in a
205
+ // browser — rather than pinning the hadoku.me constant (#419).
206
+ ...opts.apiBase ? { whoamiUrl: `${opts.apiBase.replace(/\/prefs$/, "")}/session/whoami` } : {}
207
+ });
208
+ userId = who.userId;
209
+ identityScope = who.scope;
210
+ if (!deviceId) {
211
+ deviceId = await getDeviceId(storage);
212
+ }
213
+ const cached = await readCache();
214
+ if (cached) {
215
+ userVersion = cached.userVersion;
216
+ deviceVersion = cached.deviceVersion;
217
+ currentBlob = applyDefaultsAndParse(cached.blob);
218
+ if (mirrorOnRead) mirrorBootstrap(currentBlob);
219
+ notify(currentBlob);
220
+ }
221
+ if (canPersist() && typeof BroadcastChannel !== "undefined") {
222
+ bcast = new BroadcastChannel(`prefs:${userId}:${opts.appId}`);
223
+ bcast.addEventListener("message", onBroadcast);
224
+ }
225
+ })();
226
+ return initPromise;
227
+ }
228
+ function buildHeaders() {
229
+ const h = { "Content-Type": "application/json" };
230
+ if (opts.userKey) h["X-User-Key"] = opts.userKey;
231
+ if (deviceId) h["X-Device-Id"] = deviceId;
232
+ return h;
233
+ }
234
+ async function fetchFromServer() {
235
+ const resp = await fetch(`${apiBase}/api/v1/${opts.appId}`, {
236
+ method: "GET",
237
+ headers: buildHeaders(),
238
+ credentials: "include"
239
+ });
240
+ if (!resp.ok) {
241
+ throw new Error(`prefs-api GET failed: ${resp.status}`);
242
+ }
243
+ return await resp.json();
244
+ }
245
+ async function putToServer(scope, patch, expectedVersion) {
246
+ const resp = await fetch(`${apiBase}/api/v1/${opts.appId}`, {
247
+ method: "PUT",
248
+ headers: buildHeaders(),
249
+ credentials: "include",
250
+ body: JSON.stringify({
251
+ scope,
252
+ patch,
253
+ expectedVersion
254
+ })
255
+ });
256
+ if (resp.status === 409) {
257
+ const body2 = await resp.json();
258
+ if (body2.current) {
259
+ return { ok: false, conflict: body2.current };
260
+ }
261
+ throw new Error("prefs-api 409 without current-state body");
262
+ }
263
+ if (!resp.ok) {
264
+ throw new Error(`prefs-api PUT failed: ${resp.status}`);
265
+ }
266
+ const body = await resp.json();
267
+ return { ok: true, row: body, scope: body.scope };
268
+ }
269
+ function cacheKey() {
270
+ return `${CACHE_KEY_PREFIX}:${userId ?? ANON_USER_ID}:${opts.appId}`;
271
+ }
272
+ function canPersist() {
273
+ return identityScope !== "none";
274
+ }
275
+ function canSync() {
276
+ return identityScope === "user" || identityScope === "session";
277
+ }
278
+ async function readCache() {
279
+ if (!canPersist()) return null;
280
+ const raw = await storage.get(cacheKey());
281
+ if (!raw) return null;
282
+ try {
283
+ const parsed = JSON.parse(raw);
284
+ if (parsed && typeof parsed === "object" && parsed.blob && typeof parsed.blob === "object") {
285
+ return parsed;
286
+ }
287
+ } catch {
288
+ }
289
+ return null;
290
+ }
291
+ async function writeCache(env) {
292
+ if (!canPersist()) return;
293
+ await storage.set(cacheKey(), JSON.stringify(env));
294
+ }
295
+ function applyDefaultsAndParse(rawBlob) {
296
+ let withDefaults = { ...defaults, ...rawBlob };
297
+ for (const m of migrations) {
298
+ withDefaults = m(withDefaults);
299
+ }
300
+ const result = opts.schema.safeParse(withDefaults);
301
+ if (result.success) {
302
+ return result.data;
303
+ }
304
+ const fallback = opts.schema.safeParse({ ...defaults });
305
+ return fallback.success ? fallback.data : withDefaults;
306
+ }
307
+ function mirrorBootstrap(blob) {
308
+ for (const [field, sessionKey] of Object.entries(bootstrapMap)) {
309
+ if (typeof sessionKey !== "string" || !sessionKey) continue;
310
+ const value = blob[field];
311
+ if (typeof value === "string") {
312
+ sessionStorageWrite(sessionKey, value);
313
+ }
314
+ }
315
+ }
316
+ function notify(blob) {
317
+ for (const l of listeners) {
318
+ try {
319
+ l(blob);
320
+ } catch {
321
+ }
322
+ }
323
+ }
324
+ function broadcast(blob) {
325
+ if (!bcast) return;
326
+ try {
327
+ bcast.postMessage({ source: broadcastId, blob });
328
+ } catch {
329
+ }
330
+ }
331
+ function onBroadcast(ev) {
332
+ const data = ev.data;
333
+ if (!data || data.source === broadcastId || !data.blob) return;
334
+ const reconciled = reconcileDirty(data.blob);
335
+ currentBlob = applyDefaultsAndParse(reconciled);
336
+ if (mirrorOnRead) mirrorBootstrap(currentBlob);
337
+ notify(currentBlob);
338
+ }
339
+ function reconcileDirty(remote) {
340
+ if (dirtyFields.size === 0 || !currentBlob) return remote;
341
+ const local = currentBlob;
342
+ const reconciled = { ...remote };
343
+ for (const field of dirtyFields) {
344
+ if (field in local) reconciled[field] = local[field];
345
+ }
346
+ return reconciled;
347
+ }
348
+ async function refreshFromServer() {
349
+ if (!canSync()) {
350
+ if (currentBlob === null) {
351
+ currentBlob = applyDefaultsAndParse({});
352
+ if (mirrorOnRead) mirrorBootstrap(currentBlob);
353
+ notify(currentBlob);
354
+ }
355
+ return;
356
+ }
357
+ const server = await fetchFromServer();
358
+ userVersion = server.user?.version ?? 0;
359
+ deviceVersion = server.device?.version ?? 0;
360
+ const merged = reconcileDirty(server.merged ?? {});
361
+ currentBlob = applyDefaultsAndParse(merged);
362
+ await writeCache({
363
+ blob: merged,
364
+ userVersion,
365
+ deviceVersion,
366
+ cachedAt: Date.now()
367
+ });
368
+ if (mirrorOnRead) mirrorBootstrap(currentBlob);
369
+ notify(currentBlob);
370
+ broadcast(currentBlob);
371
+ }
372
+ function scheduleFlush() {
373
+ if (saveTimer) clearTimeout(saveTimer);
374
+ saveTimer = setTimeout(() => {
375
+ void flushPending();
376
+ }, SAVE_DEBOUNCE_MS);
377
+ }
378
+ async function flushPending() {
379
+ if (Object.keys(pendingPatch).length === 0) return;
380
+ if (savePromise) {
381
+ await savePromise;
382
+ }
383
+ const patchSnapshot = pendingPatch;
384
+ const scopeSnapshot = pendingScope;
385
+ pendingPatch = {};
386
+ if (saveTimer) {
387
+ clearTimeout(saveTimer);
388
+ saveTimer = null;
389
+ }
390
+ if (!canSync()) {
391
+ for (const key of Object.keys(patchSnapshot)) {
392
+ if (!(key in pendingPatch)) dirtyFields.delete(key);
393
+ }
394
+ return;
395
+ }
396
+ const expectedVersion = scopeSnapshot === "user" ? userVersion : deviceVersion;
397
+ savePromise = (async () => {
398
+ try {
399
+ const result = await putToServer(scopeSnapshot, patchSnapshot, expectedVersion);
400
+ if (result.ok) {
401
+ if (result.scope === "user") {
402
+ userVersion = result.row.version;
403
+ } else {
404
+ deviceVersion = result.row.version;
405
+ }
406
+ await refreshFromServer();
407
+ } else {
408
+ if (scopeSnapshot === "user") {
409
+ userVersion = result.conflict.version;
410
+ } else {
411
+ deviceVersion = result.conflict.version;
412
+ }
413
+ await refreshFromServer();
414
+ }
415
+ for (const key of Object.keys(patchSnapshot)) {
416
+ if (!(key in pendingPatch)) dirtyFields.delete(key);
417
+ }
418
+ } catch {
419
+ pendingPatch = { ...patchSnapshot, ...pendingPatch };
420
+ pendingScope = scopeSnapshot;
421
+ } finally {
422
+ savePromise = null;
423
+ }
424
+ })();
425
+ await savePromise;
426
+ }
427
+ const client = {
428
+ appId: opts.appId,
429
+ async read() {
430
+ await ensureInit();
431
+ if (currentBlob === null) {
432
+ try {
433
+ await refreshFromServer();
434
+ } catch {
435
+ currentBlob = applyDefaultsAndParse({});
436
+ }
437
+ } else {
438
+ void refreshFromServer().catch(() => {
439
+ });
440
+ }
441
+ return currentBlob ?? applyDefaultsAndParse({});
442
+ },
443
+ async save(patch, saveOpts) {
444
+ await ensureInit();
445
+ const scope = saveOpts?.scope ?? "user";
446
+ if (identityScope === "anon") wroteWhileAnon = true;
447
+ const base = currentBlob ?? {};
448
+ const nextBlob = applyDefaultsAndParse({ ...base, ...patch });
449
+ currentBlob = nextBlob;
450
+ await writeCache({
451
+ blob: { ...base, ...patch },
452
+ userVersion,
453
+ deviceVersion,
454
+ cachedAt: Date.now()
455
+ });
456
+ mirrorBootstrap(nextBlob);
457
+ notify(nextBlob);
458
+ broadcast(nextBlob);
459
+ if (Object.keys(pendingPatch).length > 0 && pendingScope !== scope) {
460
+ await flushPending();
461
+ }
462
+ pendingPatch = { ...pendingPatch, ...patch };
463
+ pendingScope = scope;
464
+ for (const key of Object.keys(patch)) dirtyFields.add(key);
465
+ if (saveOpts?.expectedVersion !== void 0) {
466
+ await flushPending();
467
+ } else {
468
+ scheduleFlush();
469
+ }
470
+ },
471
+ subscribe(listener) {
472
+ listeners.add(listener);
473
+ return () => {
474
+ listeners.delete(listener);
475
+ };
476
+ },
477
+ async promoteAnonPrefsToUser() {
478
+ if (!wroteWhileAnon) return;
479
+ const anonKey = `${CACHE_KEY_PREFIX}:${ANON_USER_ID}:${opts.appId}`;
480
+ const raw = await storage.get(anonKey);
481
+ if (!raw) return;
482
+ let anon;
483
+ try {
484
+ anon = JSON.parse(raw);
485
+ } catch {
486
+ return;
487
+ }
488
+ if (!anon.blob || Object.keys(anon.blob).length === 0) return;
489
+ await ensureInit();
490
+ const server = await fetchFromServer();
491
+ if (server.user) {
492
+ await refreshFromServer();
493
+ await storage.remove(anonKey);
494
+ return;
495
+ }
496
+ await putToServer("user", anon.blob, 0);
497
+ await refreshFromServer();
498
+ await storage.remove(anonKey);
499
+ },
500
+ async flush() {
501
+ await flushPending();
502
+ },
503
+ destroy() {
504
+ if (destroyed) return;
505
+ destroyed = true;
506
+ if (saveTimer) clearTimeout(saveTimer);
507
+ if (bcast) bcast.close();
508
+ listeners.clear();
509
+ }
510
+ };
511
+ return client;
512
+ }
513
+ export {
514
+ ANON_USER_ID,
515
+ CACHE_KEY_PREFIX,
516
+ DEVICE_ID_KEY,
517
+ PREFS_API_BASE,
518
+ SAVE_DEBOUNCE_MS,
519
+ capacitorStorage,
520
+ createPrefsClient,
521
+ detectStorage,
522
+ getDeviceId,
523
+ localStorageAdapter,
524
+ memoryStorage,
525
+ sessionStorageWrite,
526
+ whoami
527
+ };
@@ -0,0 +1,20 @@
1
+ import { a as PrefsClient } from './types-DCldC4WA.js';
2
+ import 'zod';
3
+
4
+ interface UsePrefsResult<T extends Record<string, unknown>> {
5
+ prefs: T | null;
6
+ save: PrefsClient<T>['save'];
7
+ loading: boolean;
8
+ error: Error | null;
9
+ }
10
+ /**
11
+ * Reactive prefs hook. Returns `prefs: null` on the first render (before
12
+ * the cache hydrates / server responds), then the merged blob after init.
13
+ *
14
+ * Uses useSyncExternalStore so concurrent-mode tearing isn't a concern:
15
+ * the snapshot we return is the same one we'll get next render until
16
+ * the subscription fires.
17
+ */
18
+ declare function usePrefs<T extends Record<string, unknown>>(client: PrefsClient<T>): UsePrefsResult<T>;
19
+
20
+ export { PrefsClient, type UsePrefsResult, usePrefs };
package/dist/react.js ADDED
@@ -0,0 +1,54 @@
1
+ // src/react.ts
2
+ import { useEffect, useState, useSyncExternalStore } from "react";
3
+ function usePrefs(client) {
4
+ const store = useStoreFromClient(client);
5
+ const prefs = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
6
+ const [loading, setLoading] = useState(prefs === null);
7
+ const [error, setError] = useState(null);
8
+ useEffect(() => {
9
+ let cancelled = false;
10
+ setLoading(true);
11
+ setError(null);
12
+ client.read().then(() => {
13
+ if (!cancelled) setLoading(false);
14
+ }).catch((err) => {
15
+ if (cancelled) return;
16
+ setError(err instanceof Error ? err : new Error(String(err)));
17
+ setLoading(false);
18
+ });
19
+ return () => {
20
+ cancelled = true;
21
+ };
22
+ }, [client]);
23
+ const save = (patch, saveOpts) => client.save(patch, saveOpts);
24
+ return { prefs, save, loading, error };
25
+ }
26
+ var storeCache = /* @__PURE__ */ new WeakMap();
27
+ function useStoreFromClient(client) {
28
+ const cached = storeCache.get(client);
29
+ if (cached) return cached;
30
+ let snapshot = null;
31
+ const onChangeListeners = /* @__PURE__ */ new Set();
32
+ const unsubscribe = client.subscribe((next) => {
33
+ snapshot = next;
34
+ for (const l of onChangeListeners) l();
35
+ });
36
+ const store = {
37
+ subscribe: (onChange) => {
38
+ onChangeListeners.add(onChange);
39
+ return () => {
40
+ onChangeListeners.delete(onChange);
41
+ if (onChangeListeners.size === 0) {
42
+ void unsubscribe;
43
+ }
44
+ };
45
+ },
46
+ getSnapshot: () => snapshot,
47
+ getServerSnapshot: () => null
48
+ };
49
+ storeCache.set(client, store);
50
+ return store;
51
+ }
52
+ export {
53
+ usePrefs
54
+ };
@@ -0,0 +1,190 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Storage abstraction over localStorage + Capacitor Preferences.
5
+ *
6
+ * The web path is synchronous-under-the-hood (localStorage), but we expose
7
+ * an async interface uniformly so the React hook + client code never branches
8
+ * on platform. Capacitor's @capacitor/preferences is async-only; the cost of
9
+ * making the web path also async is one extra microtask per call, which is
10
+ * irrelevant against the 1s debounce timeline anyway.
11
+ *
12
+ * The Capacitor adapter is a runtime detection — we DON'T import
13
+ * @capacitor/preferences at module load time so the package stays installable
14
+ * by web-only apps without adding the peer dep. Apps that build a Capacitor
15
+ * APK pass the adapter explicitly via createPrefsClient({ storage: ... }).
16
+ *
17
+ * Adapters return Promise<...> via Promise.resolve(...) rather than `async`
18
+ * methods — the inner operations are sync, so dropping the keyword keeps the
19
+ * intent honest and silences eslint's require-await rule.
20
+ */
21
+ interface AsyncStorage {
22
+ get(key: string): Promise<string | null>;
23
+ set(key: string, value: string): Promise<void>;
24
+ remove(key: string): Promise<void>;
25
+ }
26
+ /** localStorage-backed AsyncStorage. SSR-safe (no-ops when window is absent). */
27
+ declare const localStorageAdapter: AsyncStorage;
28
+ /**
29
+ * No-op-ish storage backed by a Map. For SSR, tests with no jsdom, or the
30
+ * very first ever paint before window.localStorage exists. All operations
31
+ * succeed silently; reads return whatever was set. Lets the SDK boot in
32
+ * Node-side rendering without exploding.
33
+ */
34
+ declare const memoryStorage: () => AsyncStorage;
35
+ /**
36
+ * Same shape as AsyncStorage but synchronous — the inline FOUC-prevention
37
+ * script in <head> can't await, so sessionStorage write-through has to be
38
+ * sync. Kept separate from AsyncStorage so the type system catches anyone
39
+ * trying to use this for the main cache path.
40
+ */
41
+ declare const sessionStorageWrite: (key: string, value: string) => void;
42
+ /**
43
+ * Build an AsyncStorage backed by the Capacitor Preferences plugin, or null
44
+ * if we're not in a native Capacitor context (or the plugin isn't installed
45
+ * in the host app). Pure runtime read — no import, no peer dep.
46
+ */
47
+ declare function capacitorStorage(): AsyncStorage | null;
48
+ /**
49
+ * Resolve the default storage backend. Native Capacitor → Preferences plugin;
50
+ * everything else → localStorage. createPrefsClient calls this when the
51
+ * consumer doesn't pass an explicit `storage` adapter, so a task-mobile APK
52
+ * gets isolated native storage with zero config while web apps keep
53
+ * localStorage. The branch is a sync global read; no async, no import.
54
+ */
55
+ declare function detectStorage(): AsyncStorage;
56
+
57
+ /**
58
+ * Public type surface — the parts a consuming app reaches for. Internal
59
+ * helpers (network shapes, cache envelopes) live next to their owners.
60
+ */
61
+
62
+ /** Pref scope. Same shape as the API column; never mix this with code paths. */
63
+ type Scope = 'user' | 'device';
64
+ /**
65
+ * A migration is applied to the raw merged blob BEFORE Zod parse. Migrations
66
+ * are ordered (apply in array order) and must be idempotent — they may run
67
+ * on a blob that's already been migrated previously, depending on whether
68
+ * the cache or the server was stale at read time. Keep them as
69
+ * `(blob: any) => any` shape; the SDK doesn't try to type-check them
70
+ * against the schema because the whole point is to transform a shape that
71
+ * doesn't match the schema yet.
72
+ */
73
+ type PrefsMigration = (blob: Record<string, unknown>) => Record<string, unknown>;
74
+ interface PrefsClientOptions<S extends z.ZodType> {
75
+ /** App identifier — matches the `app_id` column. `[a-z0-9-]+`. */
76
+ appId: string;
77
+ /** Zod schema describing the prefs blob for this app. */
78
+ schema: S;
79
+ /**
80
+ * Baseline values applied when a field is absent from the merged blob.
81
+ * Server values always win over defaults. Use this to pin "what does
82
+ * the UI fall back to before the first sync".
83
+ */
84
+ defaults?: Partial<z.infer<S>>;
85
+ /**
86
+ * Migrations applied in order to the raw merged blob (post-merge, pre-parse).
87
+ * Useful for renaming a field or coercing an old value into the new shape.
88
+ * Apps own their own migration history; the SDK never reorders or
89
+ * de-duplicates them.
90
+ */
91
+ migrations?: PrefsMigration[];
92
+ /**
93
+ * Mirror specific fields to sessionStorage. Keys are SDK schema field
94
+ * names; values are the sessionStorage key the inline `<head>`
95
+ * FOUC-prevention script reads. Lets theme apply before React mounts
96
+ * without giving up the per-user / per-device split.
97
+ *
98
+ * By default this only mirrors on an explicit local save() — never on
99
+ * read/hydrate/refresh/broadcast-receive — so an app embedded under a
100
+ * host that owns the same sessionStorage key can't clobber it just by
101
+ * booting. Pass `mirrorBootstrapOnRead: true` to restore the old
102
+ * every-read mirroring, for a key nothing else writes to.
103
+ */
104
+ bootstrapToSessionStorage?: Partial<Record<keyof z.infer<S>, string>>;
105
+ /** See `bootstrapToSessionStorage`. Default: false. */
106
+ mirrorBootstrapOnRead?: boolean;
107
+ /** Override edge-router base. Default 'https://hadoku.me/prefs'. */
108
+ apiBase?: string;
109
+ /**
110
+ * Header-mode auth (Capacitor / s2s). Browser apps with the cookie
111
+ * leave this undefined.
112
+ */
113
+ userKey?: string;
114
+ /** Storage backend. Defaults to localStorage; pass a Capacitor adapter for APK builds. */
115
+ storage?: AsyncStorage;
116
+ /**
117
+ * Override the device id. If omitted, getDeviceId() reads/mints from
118
+ * localStorage `hadoku_device_id`. The CapacitorOK SDK consumer may
119
+ * want to pre-supply this from native code.
120
+ */
121
+ deviceId?: string;
122
+ }
123
+ interface PrefsRow<T> {
124
+ blob: T;
125
+ version: number;
126
+ updatedAt: number;
127
+ deviceId?: string | null;
128
+ }
129
+ /** Server response from GET /api/v1/:appId. */
130
+ interface ServerReadResult<T> {
131
+ user: PrefsRow<T> | null;
132
+ device: PrefsRow<T> | null;
133
+ merged: T;
134
+ }
135
+ interface SaveOptions {
136
+ /** Default: 'user'. Use 'device' for per-device-only fields like theme. */
137
+ scope?: Scope;
138
+ /**
139
+ * Override the auto-managed expectedVersion. The SDK always sends the
140
+ * last-known server version; you only need this for forced overwrites
141
+ * (e.g. promoting anon prefs to user on first sign-in).
142
+ */
143
+ expectedVersion?: number;
144
+ }
145
+ /**
146
+ * `T` is the app's prefs blob (a `z.infer` of its schema). It is intentionally
147
+ * UNCONSTRAINED rather than `T extends Record<string, unknown>`: under zod v4,
148
+ * `z.infer<S>` for the generic schema `S` in {@link createPrefsClient} widens to
149
+ * `unknown`, which a `Record` bound would reject at the generic definition —
150
+ * forcing every consumer's prefs type to collapse to `any`. Leaving `T` open
151
+ * lets each concrete call site keep its real inferred object type; the runtime
152
+ * blob is always an object (schema-guaranteed), and the implementation casts to
153
+ * `Record<string, unknown>` where it needs object semantics.
154
+ */
155
+ interface PrefsClient<T> {
156
+ readonly appId: string;
157
+ /**
158
+ * Resolve the schema-validated, defaults-applied, migrated prefs.
159
+ * Returns the cached value on first call (if present) and triggers a
160
+ * background refresh; subsequent calls return the freshest known value.
161
+ */
162
+ read(): Promise<T>;
163
+ /**
164
+ * Apply a partial patch. Coalesced via a 1s debounce so a slider that
165
+ * fires 30 onChange events in two seconds only PUTs twice.
166
+ */
167
+ save(patch: Partial<T>, opts?: SaveOptions): Promise<void>;
168
+ /** Subscribe to changes (incl. cross-tab via BroadcastChannel). */
169
+ subscribe(listener: (prefs: T) => void): () => void;
170
+ /**
171
+ * One-shot migration on sign-in: take the current anon-cached prefs
172
+ * and PUT them under the user scope IF the server has no user-scope row
173
+ * yet. If the server already has a row (signed in elsewhere first),
174
+ * the server wins and our anon picks are discarded.
175
+ *
176
+ * NO-OPS unless THIS client instance actually wrote a preference while
177
+ * anonymous. The anon cache row is shared by every user of the browser, so
178
+ * without that evidence its contents belong to whoever used the browser
179
+ * last — and this method writes to the SERVER, so promoting them would
180
+ * persist one user's preferences under another's identity. Safe to call
181
+ * unconditionally on sign-in; it decides for itself.
182
+ */
183
+ promoteAnonPrefsToUser(): Promise<void>;
184
+ /** Flush any pending debounced PUTs immediately. For beforeunload. */
185
+ flush(): Promise<void>;
186
+ /** Tear down (cancels timers, closes BroadcastChannel). */
187
+ destroy(): void;
188
+ }
189
+
190
+ export { type AsyncStorage as A, type PrefsClientOptions as P, type SaveOptions as S, type PrefsClient as a, type PrefsMigration as b, type PrefsRow as c, type Scope as d, type ServerReadResult as e, capacitorStorage as f, detectStorage as g, localStorageAdapter as l, memoryStorage as m, sessionStorageWrite as s };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@hadoku/prefs-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react.d.ts",
15
+ "default": "./dist/react.js"
16
+ },
17
+ "./games": {
18
+ "types": "./dist/games.d.ts",
19
+ "default": "./dist/games.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/WolffM/hadoku_site.git",
29
+ "directory": "packages/prefs-client"
30
+ },
31
+ "peerDependencies": {
32
+ "react": "^18.0.0 || ^19.0.0",
33
+ "zod": "^4.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "react": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "keywords": [
44
+ "hadoku",
45
+ "preferences",
46
+ "prefs",
47
+ "sdk",
48
+ "game-stats"
49
+ ],
50
+ "license": "ISC"
51
+ }