hazo_state 0.1.2
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/CHANGE_LOG.md +51 -0
- package/README.md +37 -0
- package/dist/client/index.d.ts +32 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +217 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/server/index.d.ts +105 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +345 -0
- package/migrations/001_hazo_app_state.sql +33 -0
- package/migrations/001_hazo_app_state_sqlite.sql +26 -0
- package/package.json +75 -0
package/CHANGE_LOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# hazo_state — Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. Versions follow semver.
|
|
4
|
+
|
|
5
|
+
## 0.1.2 — 2026-06-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **`useHazoState`: stale-closure / render-storm on inline `fallback`.**
|
|
9
|
+
When callers pass an inline literal (e.g. `fallback={[]}`), `fallback` is a
|
|
10
|
+
fresh reference every render. Including it in `useEffect`/`useCallback` deps
|
|
11
|
+
caused the GET revalidation to re-fire on every parent re-render (up to ~60fps
|
|
12
|
+
in animation loops). Fixed by storing `fallback` in a `useRef` and reading the
|
|
13
|
+
ref inside the effect — identity changes that don't affect the resource
|
|
14
|
+
(key/level/offline/endpoint) no longer trigger a new fetch.
|
|
15
|
+
|
|
16
|
+
## 0.1.1 — 2026-06-12
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **test-app: module-not-found at runtime.** `test-app/next.config.js` aliased the
|
|
20
|
+
`hazo_state` package name to the raw `../src/*.ts` source, forcing the bundler to
|
|
21
|
+
resolve NodeNext-style `'../index.js'` imports it can't handle (`Can't resolve
|
|
22
|
+
'../index.js'`). Removed the src aliases — the test-app now consumes the built
|
|
23
|
+
`dist/` via the workspace symlink and the package `exports` map, matching the
|
|
24
|
+
workspace standard.
|
|
25
|
+
- **test-app: spurious `ENOWORKSPACES` + wrong Next version.** Replaced the bare
|
|
26
|
+
`next dev` with the standard `scripts/next.mjs` launcher, which resolves the
|
|
27
|
+
package-local Next 16 (the workspace root hoists Next 14) and honors the `PORT`
|
|
28
|
+
convention (default 3300).
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- **test-app brought up to the workspace standard.** Added the `hazo_ui/test-harness`
|
|
32
|
+
shadcn sidebar (`SidebarLayout` + `AppSidebar`), an `/autotest` route
|
|
33
|
+
(`AutoTestProvider`/`AutoTestRunner`) with browser-side scenarios for the
|
|
34
|
+
client-safe surface (`resolveIds`, `ConflictError`), Tailwind v4 wiring
|
|
35
|
+
(`postcss.config.mjs` + `@source` directives), a `hazo_debug` Turbopack stub,
|
|
36
|
+
and `AGENTS.md`. Upgraded the test-app to Next 16 / React 19.
|
|
37
|
+
- Docs: normalized the feature-request `INDEX.md` to the shared table format.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- `CHANGE_LOG.md` (this file), referenced by `README.md` and `package.json` `files[]`.
|
|
41
|
+
|
|
42
|
+
## 0.1.0 — Initial
|
|
43
|
+
|
|
44
|
+
- Generic transactional KV store (`hazo_app_state`): 4 visibility levels
|
|
45
|
+
(global / scope / user-global / user), lazy TTL expiry, optimistic CAS
|
|
46
|
+
(`version` column), atomic `append`/`increment` ops, and protected
|
|
47
|
+
(immutable-after-write) entries.
|
|
48
|
+
- Turnkey Next.js route factory (`createStateRoute`) + server service (`HazoState`).
|
|
49
|
+
- Client hook (`useHazoState`) with optimistic localStorage → server sync and an
|
|
50
|
+
offline-only mode.
|
|
51
|
+
- PostgreSQL + SQLite migrations.
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# hazo_state
|
|
2
|
+
|
|
3
|
+
Generic transactional KV store for the hazo ecosystem. Provides TTL, optimistic CAS (version column), four visibility levels (global / scope / user-global / user), and protected (immutable-after-write) entries.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install hazo_state hazo_connect
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Run the migration against your database:
|
|
14
|
+
- PostgreSQL: `psql -f node_modules/hazo_state/migrations/001_hazo_app_state.sql`
|
|
15
|
+
- SQLite (local dev): apply `node_modules/hazo_state/migrations/001_hazo_app_state_sqlite.sql`
|
|
16
|
+
|
|
17
|
+
2. See [SETUP_CHECKLIST.md](./SETUP_CHECKLIST.md) for the full step-by-step.
|
|
18
|
+
|
|
19
|
+
## Peer Dependencies
|
|
20
|
+
|
|
21
|
+
| Package | Required |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `hazo_connect` | yes |
|
|
24
|
+
| `react` | optional (client entry only) |
|
|
25
|
+
|
|
26
|
+
## Exports
|
|
27
|
+
|
|
28
|
+
| Path | Contents |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `hazo_state` | Main entry (server + client-safe utilities) |
|
|
31
|
+
| `hazo_state/server` | Server-only entry (Node.js / Edge only) |
|
|
32
|
+
| `hazo_state/client` | Client-safe entry (React hooks / browser) |
|
|
33
|
+
| `hazo_state/migrations/*` | SQL migration files |
|
|
34
|
+
|
|
35
|
+
## Changelog
|
|
36
|
+
|
|
37
|
+
See [CHANGE_LOG.md](./CHANGE_LOG.md).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { StateLevel } from '../index.js';
|
|
2
|
+
export interface UseHazoStateOptions {
|
|
3
|
+
/** Visibility level. Default: 'global'. */
|
|
4
|
+
level?: StateLevel;
|
|
5
|
+
/** Returned while loading from server; also used if the key is unset. */
|
|
6
|
+
fallback?: unknown;
|
|
7
|
+
/** localStorage key prefix. Defaults to `hazo_state:<level>:<key>`. */
|
|
8
|
+
storagePrefix?: string;
|
|
9
|
+
/** Skip server sync; persist to localStorage only. */
|
|
10
|
+
offline?: boolean;
|
|
11
|
+
/** Override the default route endpoint. */
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface UseHazoStateResult {
|
|
15
|
+
value: unknown;
|
|
16
|
+
setValue: (newValue: unknown, opts?: {
|
|
17
|
+
expectedVersion?: number;
|
|
18
|
+
}) => Promise<void>;
|
|
19
|
+
append: (item: unknown, maxLen: number) => Promise<void>;
|
|
20
|
+
increment: (n?: number) => Promise<number>;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
syncing: boolean;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Hook for reading and writing a hazo_state entry.
|
|
27
|
+
*
|
|
28
|
+
* @param key - State key
|
|
29
|
+
* @param opts - Options (level, fallback, offline, endpoint)
|
|
30
|
+
*/
|
|
31
|
+
export declare function useHazoState(key: string, opts?: UseHazoStateOptions): UseHazoStateResult;
|
|
32
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAc,UAAU,EAAE,MAAM,aAAa,CAAA;AAKzD,MAAM,WAAW,mBAAmB;IAClC,2CAA2C;IAC3C,KAAK,CAAC,EAAE,UAAU,CAAA;IAClB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,sDAAsD;IACtD,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACnF,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACxD,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAC1C,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AA0BD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,mBAAwB,GAAG,kBAAkB,CA+K5F"}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* hazo_state/client — React hook for reading and writing state via the hazo-state API route.
|
|
4
|
+
*
|
|
5
|
+
* Optimistic localStorage → server sync:
|
|
6
|
+
* 1. Hydrates synchronously from localStorage on mount.
|
|
7
|
+
* 2. Revalidates from the route on mount (and when key/level changes).
|
|
8
|
+
* 3. Writes go to the server first; localStorage is updated on success.
|
|
9
|
+
* 4. CAS conflicts on write are retried (re-read + re-apply).
|
|
10
|
+
*
|
|
11
|
+
* Offline mode (offline: true or no endpoint configured):
|
|
12
|
+
* persists to localStorage only. Useful for tests and demos without a server.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { ConflictError } from '../index.js';
|
|
16
|
+
const DEFAULT_ENDPOINT = '/api/hazo-state';
|
|
17
|
+
function storageKey(key, level, prefix) {
|
|
18
|
+
return prefix ? `${prefix}:${key}` : `hazo_state:${level}:${key}`;
|
|
19
|
+
}
|
|
20
|
+
function localRead(lsKey) {
|
|
21
|
+
if (typeof window === 'undefined')
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const raw = localStorage.getItem(lsKey);
|
|
25
|
+
if (!raw)
|
|
26
|
+
return null;
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function localWrite(lsKey, entry) {
|
|
34
|
+
if (typeof window === 'undefined')
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
localStorage.setItem(lsKey, JSON.stringify(entry));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Quota exceeded or private browsing — silently ignore
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Hook for reading and writing a hazo_state entry.
|
|
45
|
+
*
|
|
46
|
+
* @param key - State key
|
|
47
|
+
* @param opts - Options (level, fallback, offline, endpoint)
|
|
48
|
+
*/
|
|
49
|
+
export function useHazoState(key, opts = {}) {
|
|
50
|
+
const { level = 'global', fallback = null, storagePrefix, offline = false, endpoint = DEFAULT_ENDPOINT, } = opts;
|
|
51
|
+
const lsKey = storageKey(key, level, storagePrefix);
|
|
52
|
+
// Initialize from localStorage synchronously to avoid SSR mismatch
|
|
53
|
+
const [value, setValueState] = useState(() => localRead(lsKey)?.value ?? fallback);
|
|
54
|
+
const [versionRef] = useState({ current: localRead(lsKey)?.version ?? 0 });
|
|
55
|
+
const [loading, setLoading] = useState(!offline);
|
|
56
|
+
const [syncing, setSyncing] = useState(false);
|
|
57
|
+
const [error, setError] = useState(null);
|
|
58
|
+
const abortRef = useRef(null);
|
|
59
|
+
// Keep the latest `fallback` available to the revalidation effect without
|
|
60
|
+
// making it a dependency. Callers commonly pass an inline literal (e.g.
|
|
61
|
+
// `fallback: []`), which is a fresh reference every render — including it in
|
|
62
|
+
// the effect deps would re-fire the GET on every parent re-render (e.g. an
|
|
63
|
+
// animation loop calling setState ~60fps), flooding the server.
|
|
64
|
+
const fallbackRef = useRef(fallback);
|
|
65
|
+
fallbackRef.current = fallback;
|
|
66
|
+
// Server revalidation. Re-runs only when the resource identity changes
|
|
67
|
+
// (key/level/offline/endpoint/lsKey) — NOT on every render.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (offline)
|
|
70
|
+
return;
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
abortRef.current = controller;
|
|
73
|
+
async function revalidate() {
|
|
74
|
+
try {
|
|
75
|
+
const url = `${endpoint}?key=${encodeURIComponent(key)}&level=${level}`;
|
|
76
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
77
|
+
if (res.status === 404) {
|
|
78
|
+
// Key doesn't exist on server — keep localStorage value or fallback
|
|
79
|
+
setLoading(false);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
throw new Error(`hazo_state: GET ${res.status}`);
|
|
84
|
+
const entry = await res.json();
|
|
85
|
+
versionRef.current = entry.version;
|
|
86
|
+
localWrite(lsKey, entry);
|
|
87
|
+
setValueState(entry.value ?? fallbackRef.current);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err.name === 'AbortError')
|
|
91
|
+
return;
|
|
92
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setLoading(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
revalidate();
|
|
99
|
+
return () => controller.abort();
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- fallback read via ref on purpose
|
|
101
|
+
}, [key, level, offline, endpoint, lsKey]);
|
|
102
|
+
const setValue = useCallback(async (newValue, setOpts) => {
|
|
103
|
+
if (offline) {
|
|
104
|
+
const entry = { value: newValue, version: versionRef.current + 1 };
|
|
105
|
+
versionRef.current = entry.version;
|
|
106
|
+
localWrite(lsKey, entry);
|
|
107
|
+
setValueState(newValue);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setSyncing(true);
|
|
111
|
+
setError(null);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(endpoint, {
|
|
114
|
+
method: 'PUT',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
key,
|
|
118
|
+
level,
|
|
119
|
+
value: newValue,
|
|
120
|
+
expectedVersion: setOpts?.expectedVersion,
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
if (res.status === 409) {
|
|
124
|
+
// CAS conflict — re-read and retry
|
|
125
|
+
const getRes = await fetch(`${endpoint}?key=${encodeURIComponent(key)}&level=${level}`);
|
|
126
|
+
if (getRes.ok) {
|
|
127
|
+
const fresh = await getRes.json();
|
|
128
|
+
versionRef.current = fresh.version;
|
|
129
|
+
localWrite(lsKey, fresh);
|
|
130
|
+
setValueState(fresh.value ?? fallbackRef.current);
|
|
131
|
+
}
|
|
132
|
+
throw new ConflictError(key);
|
|
133
|
+
}
|
|
134
|
+
if (!res.ok)
|
|
135
|
+
throw new Error(`hazo_state: PUT ${res.status}`);
|
|
136
|
+
const entry = await res.json();
|
|
137
|
+
versionRef.current = entry.version;
|
|
138
|
+
localWrite(lsKey, entry);
|
|
139
|
+
setValueState(newValue);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
setSyncing(false);
|
|
147
|
+
}
|
|
148
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- fallback read via ref on purpose
|
|
149
|
+
}, [key, level, offline, endpoint, lsKey, versionRef]);
|
|
150
|
+
const append = useCallback(async (item, maxLen) => {
|
|
151
|
+
if (offline) {
|
|
152
|
+
const current = Array.isArray(value) ? value : [];
|
|
153
|
+
const newArr = [...current, item].slice(-maxLen);
|
|
154
|
+
await setValue(newArr);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
setSyncing(true);
|
|
158
|
+
setError(null);
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(endpoint, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({ key, level, op: { op: 'append', value: item, maxLen } }),
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
throw new Error(`hazo_state: POST append ${res.status}`);
|
|
167
|
+
// Re-read updated value
|
|
168
|
+
const getRes = await fetch(`${endpoint}?key=${encodeURIComponent(key)}&level=${level}`);
|
|
169
|
+
if (getRes.ok) {
|
|
170
|
+
const entry = await getRes.json();
|
|
171
|
+
versionRef.current = entry.version;
|
|
172
|
+
localWrite(lsKey, entry);
|
|
173
|
+
setValueState(entry.value ?? fallbackRef.current);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
setSyncing(false);
|
|
182
|
+
}
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- fallback read via ref on purpose
|
|
184
|
+
}, [key, level, offline, endpoint, lsKey, value, setValue, versionRef]);
|
|
185
|
+
const increment = useCallback(async (n = 1) => {
|
|
186
|
+
if (offline) {
|
|
187
|
+
const current = typeof value === 'number' ? value : 0;
|
|
188
|
+
const newNum = current + n;
|
|
189
|
+
await setValue(newNum);
|
|
190
|
+
return newNum;
|
|
191
|
+
}
|
|
192
|
+
setSyncing(true);
|
|
193
|
+
setError(null);
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(endpoint, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({ key, level, op: { op: 'increment', n } }),
|
|
199
|
+
});
|
|
200
|
+
if (!res.ok)
|
|
201
|
+
throw new Error(`hazo_state: POST increment ${res.status}`);
|
|
202
|
+
const data = await res.json();
|
|
203
|
+
setValueState(data.value);
|
|
204
|
+
localWrite(lsKey, { value: data.value, version: versionRef.current + 1 });
|
|
205
|
+
versionRef.current += 1;
|
|
206
|
+
return data.value;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
setSyncing(false);
|
|
214
|
+
}
|
|
215
|
+
}, [key, level, offline, endpoint, lsKey, value, setValue, versionRef]);
|
|
216
|
+
return { value, setValue, append, increment, loading, syncing, error };
|
|
217
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hazo_state — shared types and constants.
|
|
3
|
+
*
|
|
4
|
+
* Import server-side helpers from `hazo_state/server`.
|
|
5
|
+
* Import client-side hooks from `hazo_state/client`.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Controls how scope_id and user_id are resolved for a state entry.
|
|
9
|
+
*
|
|
10
|
+
* | level | scope_id | user_id |
|
|
11
|
+
* |--------------|------------|------------|
|
|
12
|
+
* | global | NULL | NULL | platform-wide (default)
|
|
13
|
+
* | scope | auth scope | NULL | org-shared
|
|
14
|
+
* | user_global | NULL | auth user | per-user across orgs
|
|
15
|
+
* | user | auth scope | auth user | personal, cross-device
|
|
16
|
+
*/
|
|
17
|
+
export type StateLevel = 'global' | 'scope' | 'user_global' | 'user';
|
|
18
|
+
/** A single row from hazo_app_state. */
|
|
19
|
+
export interface StateRecord {
|
|
20
|
+
id: string;
|
|
21
|
+
scope_id: string | null;
|
|
22
|
+
user_id: string | null;
|
|
23
|
+
state_key: string;
|
|
24
|
+
value: unknown;
|
|
25
|
+
protected: boolean;
|
|
26
|
+
version: number;
|
|
27
|
+
expires_at: string | null;
|
|
28
|
+
created_at: string;
|
|
29
|
+
updated_at: string;
|
|
30
|
+
}
|
|
31
|
+
/** Minimal resolved value returned from get/set operations. */
|
|
32
|
+
export interface StateEntry {
|
|
33
|
+
value: unknown;
|
|
34
|
+
version: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Thrown by `set` when an `expectedVersion` CAS guard fails.
|
|
38
|
+
* The caller should re-read and retry.
|
|
39
|
+
*/
|
|
40
|
+
export declare class ConflictError extends Error {
|
|
41
|
+
readonly type = "ConflictError";
|
|
42
|
+
constructor(key: string);
|
|
43
|
+
}
|
|
44
|
+
export type StateOp = {
|
|
45
|
+
op: 'append';
|
|
46
|
+
value: unknown;
|
|
47
|
+
maxLen: number;
|
|
48
|
+
} | {
|
|
49
|
+
op: 'increment';
|
|
50
|
+
n: number;
|
|
51
|
+
};
|
|
52
|
+
/** GET query params */
|
|
53
|
+
export interface GetParams {
|
|
54
|
+
key: string;
|
|
55
|
+
level?: StateLevel;
|
|
56
|
+
}
|
|
57
|
+
/** PUT body for plain set */
|
|
58
|
+
export interface SetBody {
|
|
59
|
+
key: string;
|
|
60
|
+
value: unknown;
|
|
61
|
+
level?: StateLevel;
|
|
62
|
+
expectedVersion?: number;
|
|
63
|
+
expiresAt?: string;
|
|
64
|
+
}
|
|
65
|
+
/** POST body for atomic ops (append / increment) */
|
|
66
|
+
export interface OpBody {
|
|
67
|
+
key: string;
|
|
68
|
+
level?: StateLevel;
|
|
69
|
+
op: StateOp;
|
|
70
|
+
}
|
|
71
|
+
/** DELETE query params */
|
|
72
|
+
export interface DeleteParams {
|
|
73
|
+
key: string;
|
|
74
|
+
level?: StateLevel;
|
|
75
|
+
}
|
|
76
|
+
/** Auth context resolved per-request by the app's `resolveAuth`. */
|
|
77
|
+
export interface ResolvedAuth {
|
|
78
|
+
scope_id: string | null;
|
|
79
|
+
user_id: string | null;
|
|
80
|
+
/** Returns true if the caller has the given permission. */
|
|
81
|
+
hasPermission(perm: string): boolean;
|
|
82
|
+
}
|
|
83
|
+
/** Map level → (scope_id, user_id) given resolved auth. Pure helper. */
|
|
84
|
+
export declare function resolveIds(level: StateLevel | undefined, auth: Pick<ResolvedAuth, 'scope_id' | 'user_id'>): {
|
|
85
|
+
scope_id: string | null;
|
|
86
|
+
user_id: string | null;
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;;;;;;;GASG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,aAAa,GAAG,MAAM,CAAA;AAIpE,wCAAwC;AACxC,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,OAAO,CAAA;IACd,SAAS,EAAE,OAAO,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,+DAA+D;AAC/D,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,OAAO,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;CAChB;AAID;;;GAGG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,mBAAkB;gBACnB,GAAG,EAAE,MAAM;CAIxB;AAID,MAAM,MAAM,OAAO,GACf;IAAE,EAAE,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,EAAE,EAAE,WAAW,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAElC,uBAAuB;AACvB,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,UAAU,CAAA;CACnB;AAED,6BAA6B;AAC7B,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,UAAU,CAAA;IAClB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,oDAAoD;AACpD,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,UAAU,CAAA;IAClB,EAAE,EAAE,OAAO,CAAA;CACZ;AAED,0BAA0B;AAC1B,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,UAAU,CAAA;CACnB;AAID,oEAAoE;AACpE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,2DAA2D;IAC3D,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CACrC;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CACxB,KAAK,EAAE,UAAU,YAAW,EAC5B,IAAI,EAAE,IAAI,CAAC,YAAY,EAAE,UAAU,GAAG,SAAS,CAAC,GAC/C;IAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAOrD"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hazo_state — shared types and constants.
|
|
3
|
+
*
|
|
4
|
+
* Import server-side helpers from `hazo_state/server`.
|
|
5
|
+
* Import client-side hooks from `hazo_state/client`.
|
|
6
|
+
*/
|
|
7
|
+
// ─── Errors ──────────────────────────────────────────────────────────────────
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by `set` when an `expectedVersion` CAS guard fails.
|
|
10
|
+
* The caller should re-read and retry.
|
|
11
|
+
*/
|
|
12
|
+
export class ConflictError extends Error {
|
|
13
|
+
constructor(key) {
|
|
14
|
+
super(`hazo_state: optimistic conflict on key "${key}" — re-read and retry`);
|
|
15
|
+
this.type = 'ConflictError';
|
|
16
|
+
this.name = 'ConflictError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Map level → (scope_id, user_id) given resolved auth. Pure helper. */
|
|
20
|
+
export function resolveIds(level = 'global', auth) {
|
|
21
|
+
switch (level) {
|
|
22
|
+
case 'global': return { scope_id: null, user_id: null };
|
|
23
|
+
case 'scope': return { scope_id: auth.scope_id ?? null, user_id: null };
|
|
24
|
+
case 'user_global': return { scope_id: null, user_id: auth.user_id ?? null };
|
|
25
|
+
case 'user': return { scope_id: auth.scope_id ?? null, user_id: auth.user_id ?? null };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hazo_state/server — server-side state service and Next.js route factory.
|
|
3
|
+
*
|
|
4
|
+
* Values are always JSON-serialized before storage so the same
|
|
5
|
+
* HazoState service works with both SQLite (TEXT column in test-apps)
|
|
6
|
+
* and PostgREST (JSONB column in production).
|
|
7
|
+
*/
|
|
8
|
+
import 'server-only';
|
|
9
|
+
import type { HazoConnectAdapter } from 'hazo_connect/server';
|
|
10
|
+
import { type StateEntry, type ResolvedAuth } from '../index.js';
|
|
11
|
+
/** Specification for a key pattern that requires a permission to write. */
|
|
12
|
+
export interface ProtectedKeySpec {
|
|
13
|
+
/** Exact key string, or a RegExp that is tested against the key. */
|
|
14
|
+
pattern: string | RegExp;
|
|
15
|
+
/** Permission checked via resolveAuth.hasPermission. Defaults to 'manage_global_config'. */
|
|
16
|
+
permission?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface GetOptions {
|
|
19
|
+
scope_id?: string | null;
|
|
20
|
+
user_id?: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface SetOptions extends GetOptions {
|
|
23
|
+
/** When provided, acts as a CAS guard: throws ConflictError if version mismatches. */
|
|
24
|
+
expectedVersion?: number;
|
|
25
|
+
/** ISO 8601 timestamp. null clears any existing TTL. */
|
|
26
|
+
expiresAt?: string | null;
|
|
27
|
+
/** Stamp the row as protected on initial insert. Immutable after creation. */
|
|
28
|
+
isProtected?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Generic KV state service backed by hazo_app_state via hazo_connect.
|
|
32
|
+
*
|
|
33
|
+
* All operations accept explicit scope_id / user_id — callers are responsible
|
|
34
|
+
* for resolving these from auth (the createStateRoute factory handles this).
|
|
35
|
+
*/
|
|
36
|
+
export declare class HazoState {
|
|
37
|
+
private readonly db;
|
|
38
|
+
constructor(db: HazoConnectAdapter);
|
|
39
|
+
private buildKeyQuery;
|
|
40
|
+
private _getRaw;
|
|
41
|
+
/**
|
|
42
|
+
* Get a state entry. Returns null if the key is absent or the TTL has expired (lazy).
|
|
43
|
+
*/
|
|
44
|
+
get(key: string, opts?: GetOptions): Promise<StateEntry | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Set a state entry.
|
|
47
|
+
*
|
|
48
|
+
* - Without expectedVersion: blind upsert (read → insert or patch).
|
|
49
|
+
* - With expectedVersion: CAS; throws ConflictError on mismatch.
|
|
50
|
+
* Pass expectedVersion=0 to assert the key does not yet exist.
|
|
51
|
+
*
|
|
52
|
+
* Returns the new value + version.
|
|
53
|
+
*/
|
|
54
|
+
set(key: string, value: unknown, opts?: SetOptions): Promise<StateEntry>;
|
|
55
|
+
/**
|
|
56
|
+
* Atomically append an item to a stored array, capping at maxLen (oldest items dropped).
|
|
57
|
+
* Uses a CAS retry loop — safe under concurrent writes.
|
|
58
|
+
*/
|
|
59
|
+
appendToWindow(key: string, item: unknown, maxLen: number, opts?: GetOptions): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Atomically increment a stored number by n (default 1).
|
|
62
|
+
* Uses a CAS retry loop. Returns the new value.
|
|
63
|
+
*/
|
|
64
|
+
increment(key: string, n?: number, opts?: GetOptions): Promise<number>;
|
|
65
|
+
/**
|
|
66
|
+
* Delete a state entry. No-op if absent.
|
|
67
|
+
*/
|
|
68
|
+
delete(key: string, opts?: GetOptions): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Delete all rows where expires_at is in the past.
|
|
71
|
+
* Intended to be called from a scheduled job / cron.
|
|
72
|
+
*/
|
|
73
|
+
sweepExpired(): Promise<number>;
|
|
74
|
+
}
|
|
75
|
+
/** Options for createStateRoute. */
|
|
76
|
+
export interface StateRouteOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the caller's auth context from the incoming request.
|
|
79
|
+
* The app injects this — auth is app-specific.
|
|
80
|
+
*/
|
|
81
|
+
resolveAuth: (req: Request) => Promise<ResolvedAuth>;
|
|
82
|
+
/**
|
|
83
|
+
* Keys matching these specs are stamped protected=true on creation and
|
|
84
|
+
* require hasPermission(spec.permission) on write.
|
|
85
|
+
*/
|
|
86
|
+
protectedKeys?: ProtectedKeySpec[];
|
|
87
|
+
}
|
|
88
|
+
type RouteHandlers = {
|
|
89
|
+
GET: (req: Request) => Promise<Response>;
|
|
90
|
+
PUT: (req: Request) => Promise<Response>;
|
|
91
|
+
POST: (req: Request) => Promise<Response>;
|
|
92
|
+
DELETE: (req: Request) => Promise<Response>;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Create Next.js route handlers for hazo_state.
|
|
96
|
+
*
|
|
97
|
+
* Mount at `app/api/hazo-state/route.ts`:
|
|
98
|
+
* ```ts
|
|
99
|
+
* const handlers = createStateRoute(getHazoConnectSingleton(), { resolveAuth, protectedKeys })
|
|
100
|
+
* export const { GET, PUT, POST, DELETE } = handlers
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export declare function createStateRoute(db: HazoConnectAdapter, opts: StateRouteOptions): RouteHandlers;
|
|
104
|
+
export {};
|
|
105
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,aAAa,CAAA;AAOpB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAE7D,OAAO,EAIL,KAAK,UAAU,EAEf,KAAK,YAAY,EAGlB,MAAM,aAAa,CAAA;AAIpB,2EAA2E;AAC3E,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA8BD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,UAAW,SAAQ,UAAU;IAC5C,sFAAsF;IACtF,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB;AAID;;;;;GAKG;AACH,qBAAa,SAAS;IACR,OAAO,CAAC,QAAQ,CAAC,EAAE;gBAAF,EAAE,EAAE,kBAAkB;IAEnD,OAAO,CAAC,aAAa;YAgBP,OAAO;IAQrB;;OAEG;IACG,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAOzE;;;;;;;;OAQG;IACG,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,UAAU,CAAC;IAkDlF;;;OAGG;IACG,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBtG;;;OAGG;IACG,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,SAAI,EAAE,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,MAAM,CAAC;IAoB3E;;OAEG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAK/D;;;OAGG;IACG,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC;CActC;AAID,oCAAoC;AACpC,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,YAAY,CAAC,CAAA;IACpD;;;OAGG;IACH,aAAa,CAAC,EAAE,gBAAgB,EAAE,CAAA;CACnC;AAED,KAAK,aAAa,GAAG;IACnB,GAAG,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IACxC,GAAG,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IACxC,IAAI,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IACzC,MAAM,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC5C,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,iBAAiB,GACtB,aAAa,CA6Hf"}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hazo_state/server — server-side state service and Next.js route factory.
|
|
3
|
+
*
|
|
4
|
+
* Values are always JSON-serialized before storage so the same
|
|
5
|
+
* HazoState service works with both SQLite (TEXT column in test-apps)
|
|
6
|
+
* and PostgREST (JSONB column in production).
|
|
7
|
+
*/
|
|
8
|
+
import 'server-only';
|
|
9
|
+
import { QueryBuilder, attachExecute, parseJsonbField, } from 'hazo_connect/server';
|
|
10
|
+
import { ConflictError, resolveIds, } from '../index.js';
|
|
11
|
+
function matchesProtectedKey(key, specs) {
|
|
12
|
+
for (const spec of specs) {
|
|
13
|
+
if (spec.pattern instanceof RegExp) {
|
|
14
|
+
if (spec.pattern.test(key))
|
|
15
|
+
return spec;
|
|
16
|
+
}
|
|
17
|
+
else if (key === spec.pattern) {
|
|
18
|
+
return spec;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
// ─── Value serialization ──────────────────────────────────────────────────────
|
|
24
|
+
function serialize(value) {
|
|
25
|
+
return JSON.stringify({ _v: value });
|
|
26
|
+
}
|
|
27
|
+
function deserialize(raw) {
|
|
28
|
+
const parsed = parseJsonbField(raw);
|
|
29
|
+
if (parsed !== null && typeof parsed === 'object' && '_v' in parsed) {
|
|
30
|
+
return parsed._v;
|
|
31
|
+
}
|
|
32
|
+
// Fallback: try direct parse for legacy rows
|
|
33
|
+
return parseJsonbField(raw);
|
|
34
|
+
}
|
|
35
|
+
// ─── HazoState service ───────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Generic KV state service backed by hazo_app_state via hazo_connect.
|
|
38
|
+
*
|
|
39
|
+
* All operations accept explicit scope_id / user_id — callers are responsible
|
|
40
|
+
* for resolving these from auth (the createStateRoute factory handles this).
|
|
41
|
+
*/
|
|
42
|
+
export class HazoState {
|
|
43
|
+
constructor(db) {
|
|
44
|
+
this.db = db;
|
|
45
|
+
}
|
|
46
|
+
buildKeyQuery(key, scope_id, user_id) {
|
|
47
|
+
const qb = new QueryBuilder().from('hazo_app_state');
|
|
48
|
+
qb.where('state_key', 'eq', key);
|
|
49
|
+
if (scope_id === null) {
|
|
50
|
+
qb.where('scope_id', 'is', null);
|
|
51
|
+
}
|
|
52
|
+
else if (scope_id !== undefined) {
|
|
53
|
+
qb.where('scope_id', 'eq', scope_id);
|
|
54
|
+
}
|
|
55
|
+
if (user_id === null) {
|
|
56
|
+
qb.where('user_id', 'is', null);
|
|
57
|
+
}
|
|
58
|
+
else if (user_id !== undefined) {
|
|
59
|
+
qb.where('user_id', 'eq', user_id);
|
|
60
|
+
}
|
|
61
|
+
return qb;
|
|
62
|
+
}
|
|
63
|
+
async _getRaw(key, scope_id, user_id) {
|
|
64
|
+
const qb = this.buildKeyQuery(key, scope_id, user_id);
|
|
65
|
+
const rows = await attachExecute(qb, this.db).execute('GET');
|
|
66
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
const row = rows[0];
|
|
69
|
+
return { ...row, version: Number(row.version) };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Get a state entry. Returns null if the key is absent or the TTL has expired (lazy).
|
|
73
|
+
*/
|
|
74
|
+
async get(key, opts = {}) {
|
|
75
|
+
const row = await this._getRaw(key, opts.scope_id, opts.user_id);
|
|
76
|
+
if (!row)
|
|
77
|
+
return null;
|
|
78
|
+
if (row.expires_at && new Date(row.expires_at) <= new Date())
|
|
79
|
+
return null;
|
|
80
|
+
return { value: deserialize(row.value), version: row.version };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Set a state entry.
|
|
84
|
+
*
|
|
85
|
+
* - Without expectedVersion: blind upsert (read → insert or patch).
|
|
86
|
+
* - With expectedVersion: CAS; throws ConflictError on mismatch.
|
|
87
|
+
* Pass expectedVersion=0 to assert the key does not yet exist.
|
|
88
|
+
*
|
|
89
|
+
* Returns the new value + version.
|
|
90
|
+
*/
|
|
91
|
+
async set(key, value, opts = {}) {
|
|
92
|
+
const { scope_id, user_id, expectedVersion, expiresAt, isProtected } = opts;
|
|
93
|
+
const existing = await this._getRaw(key, scope_id, user_id);
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
const serialized = serialize(value);
|
|
96
|
+
if (existing) {
|
|
97
|
+
const newVersion = existing.version + 1;
|
|
98
|
+
const patch = {
|
|
99
|
+
value: serialized,
|
|
100
|
+
version: newVersion,
|
|
101
|
+
updated_at: now,
|
|
102
|
+
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
|
|
103
|
+
};
|
|
104
|
+
const qb = this.buildKeyQuery(key, scope_id, user_id);
|
|
105
|
+
if (expectedVersion !== undefined) {
|
|
106
|
+
qb.where('version', 'eq', expectedVersion);
|
|
107
|
+
}
|
|
108
|
+
const result = await attachExecute(qb, this.db).execute('PATCH', patch);
|
|
109
|
+
if (Array.isArray(result) && result.length === 0) {
|
|
110
|
+
throw new ConflictError(key);
|
|
111
|
+
}
|
|
112
|
+
return { value, version: newVersion };
|
|
113
|
+
}
|
|
114
|
+
// Insert new row
|
|
115
|
+
if (expectedVersion !== undefined && expectedVersion !== 0) {
|
|
116
|
+
throw new ConflictError(key);
|
|
117
|
+
}
|
|
118
|
+
const id = crypto.randomUUID();
|
|
119
|
+
await attachExecute(new QueryBuilder().from('hazo_app_state'), this.db).execute('POST', {
|
|
120
|
+
id,
|
|
121
|
+
state_key: key,
|
|
122
|
+
scope_id: scope_id ?? null,
|
|
123
|
+
user_id: user_id ?? null,
|
|
124
|
+
value: serialized,
|
|
125
|
+
protected: isProtected ? 1 : 0,
|
|
126
|
+
version: 1,
|
|
127
|
+
expires_at: expiresAt ?? null,
|
|
128
|
+
created_at: now,
|
|
129
|
+
updated_at: now,
|
|
130
|
+
});
|
|
131
|
+
return { value, version: 1 };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Atomically append an item to a stored array, capping at maxLen (oldest items dropped).
|
|
135
|
+
* Uses a CAS retry loop — safe under concurrent writes.
|
|
136
|
+
*/
|
|
137
|
+
async appendToWindow(key, item, maxLen, opts = {}) {
|
|
138
|
+
const MAX_RETRIES = 10;
|
|
139
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
140
|
+
const current = await this.get(key, opts);
|
|
141
|
+
const arr = Array.isArray(current?.value) ? current.value : [];
|
|
142
|
+
const newArr = [...arr, item].slice(-maxLen);
|
|
143
|
+
try {
|
|
144
|
+
await this.set(key, newArr, {
|
|
145
|
+
...opts,
|
|
146
|
+
expectedVersion: current ? current.version : 0,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof ConflictError && attempt < MAX_RETRIES - 1)
|
|
152
|
+
continue;
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Atomically increment a stored number by n (default 1).
|
|
159
|
+
* Uses a CAS retry loop. Returns the new value.
|
|
160
|
+
*/
|
|
161
|
+
async increment(key, n = 1, opts = {}) {
|
|
162
|
+
const MAX_RETRIES = 10;
|
|
163
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
164
|
+
const current = await this.get(key, opts);
|
|
165
|
+
const num = typeof current?.value === 'number' ? current.value : 0;
|
|
166
|
+
const newNum = num + n;
|
|
167
|
+
try {
|
|
168
|
+
await this.set(key, newNum, {
|
|
169
|
+
...opts,
|
|
170
|
+
expectedVersion: current ? current.version : 0,
|
|
171
|
+
});
|
|
172
|
+
return newNum;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err instanceof ConflictError && attempt < MAX_RETRIES - 1)
|
|
176
|
+
continue;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new ConflictError(key);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Delete a state entry. No-op if absent.
|
|
184
|
+
*/
|
|
185
|
+
async delete(key, opts = {}) {
|
|
186
|
+
const qb = this.buildKeyQuery(key, opts.scope_id, opts.user_id);
|
|
187
|
+
await attachExecute(qb, this.db).execute('DELETE');
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Delete all rows where expires_at is in the past.
|
|
191
|
+
* Intended to be called from a scheduled job / cron.
|
|
192
|
+
*/
|
|
193
|
+
async sweepExpired() {
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
const qb = new QueryBuilder().from('hazo_app_state');
|
|
196
|
+
qb.where('expires_at', 'lt', now);
|
|
197
|
+
const rows = await attachExecute(qb, this.db).execute('GET');
|
|
198
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
199
|
+
return 0;
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const delQb = new QueryBuilder().from('hazo_app_state').where('id', 'eq', row.id);
|
|
202
|
+
await attachExecute(delQb, this.db).execute('DELETE');
|
|
203
|
+
}
|
|
204
|
+
return rows.length;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Create Next.js route handlers for hazo_state.
|
|
209
|
+
*
|
|
210
|
+
* Mount at `app/api/hazo-state/route.ts`:
|
|
211
|
+
* ```ts
|
|
212
|
+
* const handlers = createStateRoute(getHazoConnectSingleton(), { resolveAuth, protectedKeys })
|
|
213
|
+
* export const { GET, PUT, POST, DELETE } = handlers
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
export function createStateRoute(db, opts) {
|
|
217
|
+
const service = new HazoState(db);
|
|
218
|
+
const { resolveAuth, protectedKeys = [] } = opts;
|
|
219
|
+
async function resolveGetOpts(_key, level = 'global', auth) {
|
|
220
|
+
return resolveIds(level, auth);
|
|
221
|
+
}
|
|
222
|
+
async function checkProtectedWrite(key, auth) {
|
|
223
|
+
const spec = matchesProtectedKey(key, protectedKeys);
|
|
224
|
+
if (!spec)
|
|
225
|
+
return null;
|
|
226
|
+
const perm = spec.permission ?? 'manage_global_config';
|
|
227
|
+
if (!auth.hasPermission(perm)) {
|
|
228
|
+
return new Response(JSON.stringify({ error: 'Forbidden', permission: perm }), {
|
|
229
|
+
status: 403,
|
|
230
|
+
headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
async GET(req) {
|
|
237
|
+
try {
|
|
238
|
+
const url = new URL(req.url);
|
|
239
|
+
const key = url.searchParams.get('key');
|
|
240
|
+
if (!key)
|
|
241
|
+
return jsonError('key is required', 400);
|
|
242
|
+
const level = (url.searchParams.get('level') ?? 'global');
|
|
243
|
+
const auth = await resolveAuth(req);
|
|
244
|
+
const getOpts = await resolveGetOpts(key, level, auth);
|
|
245
|
+
const entry = await service.get(key, getOpts);
|
|
246
|
+
if (!entry)
|
|
247
|
+
return new Response(null, { status: 404 });
|
|
248
|
+
return jsonOk(entry);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
return jsonError(errorMessage(err), 500);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
async PUT(req) {
|
|
255
|
+
try {
|
|
256
|
+
const body = await req.json();
|
|
257
|
+
const { key, value, level = 'global', expectedVersion, expiresAt } = body;
|
|
258
|
+
if (!key)
|
|
259
|
+
return jsonError('key is required', 400);
|
|
260
|
+
const auth = await resolveAuth(req);
|
|
261
|
+
const guardRes = await checkProtectedWrite(key, auth);
|
|
262
|
+
if (guardRes)
|
|
263
|
+
return guardRes;
|
|
264
|
+
const getOpts = await resolveGetOpts(key, level, auth);
|
|
265
|
+
const spec = matchesProtectedKey(key, protectedKeys);
|
|
266
|
+
const entry = await service.set(key, value, {
|
|
267
|
+
...getOpts,
|
|
268
|
+
expectedVersion,
|
|
269
|
+
expiresAt,
|
|
270
|
+
isProtected: spec !== null,
|
|
271
|
+
});
|
|
272
|
+
return jsonOk(entry);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
if (err instanceof ConflictError)
|
|
276
|
+
return jsonError(err.message, 409);
|
|
277
|
+
return jsonError(errorMessage(err), 500);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
async POST(req) {
|
|
281
|
+
try {
|
|
282
|
+
const body = await req.json();
|
|
283
|
+
const { key, level = 'global', op } = body;
|
|
284
|
+
if (!key)
|
|
285
|
+
return jsonError('key is required', 400);
|
|
286
|
+
if (!op)
|
|
287
|
+
return jsonError('op is required', 400);
|
|
288
|
+
const auth = await resolveAuth(req);
|
|
289
|
+
const guardRes = await checkProtectedWrite(key, auth);
|
|
290
|
+
if (guardRes)
|
|
291
|
+
return guardRes;
|
|
292
|
+
const getOpts = await resolveGetOpts(key, level, auth);
|
|
293
|
+
if (op.op === 'append') {
|
|
294
|
+
await service.appendToWindow(key, op.value, op.maxLen, getOpts);
|
|
295
|
+
return jsonOk({ ok: true });
|
|
296
|
+
}
|
|
297
|
+
if (op.op === 'increment') {
|
|
298
|
+
const newVal = await service.increment(key, op.n, getOpts);
|
|
299
|
+
return jsonOk({ value: newVal });
|
|
300
|
+
}
|
|
301
|
+
return jsonError('unknown op', 400);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
if (err instanceof ConflictError)
|
|
305
|
+
return jsonError(err.message, 409);
|
|
306
|
+
return jsonError(errorMessage(err), 500);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
async DELETE(req) {
|
|
310
|
+
try {
|
|
311
|
+
const url = new URL(req.url);
|
|
312
|
+
const key = url.searchParams.get('key');
|
|
313
|
+
if (!key)
|
|
314
|
+
return jsonError('key is required', 400);
|
|
315
|
+
const level = (url.searchParams.get('level') ?? 'global');
|
|
316
|
+
const auth = await resolveAuth(req);
|
|
317
|
+
const guardRes = await checkProtectedWrite(key, auth);
|
|
318
|
+
if (guardRes)
|
|
319
|
+
return guardRes;
|
|
320
|
+
const getOpts = await resolveGetOpts(key, level, auth);
|
|
321
|
+
await service.delete(key, getOpts);
|
|
322
|
+
return new Response(null, { status: 204 });
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return jsonError(errorMessage(err), 500);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
331
|
+
function jsonOk(data) {
|
|
332
|
+
return new Response(JSON.stringify(data), {
|
|
333
|
+
status: 200,
|
|
334
|
+
headers: { 'Content-Type': 'application/json' },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function jsonError(message, status) {
|
|
338
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
339
|
+
status,
|
|
340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function errorMessage(err) {
|
|
344
|
+
return err instanceof Error ? err.message : String(err);
|
|
345
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- hazo_app_state: generic transactional KV store for the hazo ecosystem.
|
|
2
|
+
-- Supports 4 visibility levels (global/scope/user_global/user), TTL, optimistic
|
|
3
|
+
-- concurrency (version column), and protected/gated-write entries.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS hazo_app_state (
|
|
6
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7
|
+
scope_id UUID, -- NULL = not scope-bound
|
|
8
|
+
user_id UUID, -- NULL = not user-bound
|
|
9
|
+
state_key TEXT NOT NULL,
|
|
10
|
+
value JSONB,
|
|
11
|
+
protected BOOLEAN NOT NULL DEFAULT FALSE, -- immutable after creation; gated-write
|
|
12
|
+
version BIGINT NOT NULL DEFAULT 1, -- optimistic-concurrency token
|
|
13
|
+
expires_at TIMESTAMPTZ, -- NULL = no expiry (lazy TTL)
|
|
14
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
15
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
-- Unique constraint: one entry per (scope_id, user_id, state_key) combination.
|
|
19
|
+
-- NULL values in scope_id/user_id are treated as distinct levels, not wildcards.
|
|
20
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_app_state_unique
|
|
21
|
+
ON hazo_app_state (
|
|
22
|
+
COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
23
|
+
COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
24
|
+
state_key
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- Partial index for efficient TTL sweep.
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_app_state_expires
|
|
29
|
+
ON hazo_app_state (expires_at)
|
|
30
|
+
WHERE expires_at IS NOT NULL;
|
|
31
|
+
|
|
32
|
+
-- PostgREST requires explicit grants.
|
|
33
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON hazo_app_state TO api_user;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- SQLite-compatible variant of 001_hazo_app_state.sql for test-apps and local dev.
|
|
2
|
+
-- Differences: no gen_random_uuid() (app provides id), no partial indexes, no GRANT.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS hazo_app_state (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
scope_id TEXT,
|
|
7
|
+
user_id TEXT,
|
|
8
|
+
state_key TEXT NOT NULL,
|
|
9
|
+
value TEXT, -- JSON stored as text in SQLite
|
|
10
|
+
protected INTEGER NOT NULL DEFAULT 0, -- 0=false, 1=true
|
|
11
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
12
|
+
expires_at TEXT, -- ISO 8601 string
|
|
13
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
14
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_app_state_unique
|
|
18
|
+
ON hazo_app_state (
|
|
19
|
+
COALESCE(scope_id, ''),
|
|
20
|
+
COALESCE(user_id, ''),
|
|
21
|
+
state_key
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_app_state_expires
|
|
25
|
+
ON hazo_app_state (expires_at)
|
|
26
|
+
WHERE expires_at IS NOT NULL;
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hazo_state",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Generic transactional KV store for the hazo ecosystem — TTL, optimistic CAS, atomic helpers, turnkey route/client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"migrations",
|
|
11
|
+
"CHANGE_LOG.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./server": {
|
|
20
|
+
"types": "./dist/server/index.d.ts",
|
|
21
|
+
"import": "./dist/server/index.js",
|
|
22
|
+
"default": "./dist/server/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./client": {
|
|
25
|
+
"types": "./dist/client/index.d.ts",
|
|
26
|
+
"import": "./dist/client/index.js",
|
|
27
|
+
"default": "./dist/client/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./migrations/*": "./migrations/*"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"type-check": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run build",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"dev:test-app": "npm run build && cd test-app && npm run dev",
|
|
38
|
+
"build:test-app": "npm run build && cd test-app && npm run build"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/pub12/hazo_state.git"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"state",
|
|
46
|
+
"kv",
|
|
47
|
+
"store",
|
|
48
|
+
"ttl",
|
|
49
|
+
"cas",
|
|
50
|
+
"hazo"
|
|
51
|
+
],
|
|
52
|
+
"author": "Pubs Abayasiri",
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=18.0.0"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"hazo_connect": "^3.5.1",
|
|
59
|
+
"server-only": "^0.0.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^20.14.10",
|
|
63
|
+
"@types/react": "^18.3.3",
|
|
64
|
+
"typescript": "^5.7.2",
|
|
65
|
+
"vitest": "^1.6.1"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"react": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|