@uniweb/api 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react'
2
+ import { getClient } from '../client.js'
3
+ import { ApiError } from '../errors.js'
4
+ import { FIELD, OP } from '../wire.js'
5
+
6
+ const IDLE = 'idle'
7
+ const SAVING = 'saving'
8
+ const ERROR = 'error'
9
+
10
+ /**
11
+ * Write the items of one entity, in domain terms.
12
+ *
13
+ * ```jsx
14
+ * const programme = useEntityWriter({ schema: '@/track', uuid: track.uuid })
15
+ * await programme.create({ title: 'Keynote' }) // appended
16
+ * await programme.update(itemId, { room: 'Hall A' })
17
+ * await programme.move(itemId, { after: otherItemId }) // the organiser arranges
18
+ * await programme.remove(itemId)
19
+ * ```
20
+ *
21
+ * ## ⭐ Why a hook wraps ops at all — it is a CCA argument, not an ergonomic one
22
+ *
23
+ * A component that composes `{ kind: 'update', item_id, if_unmodified_since }` has
24
+ * coupled itself to the wire, which is exactly what a foundation must not do: the
25
+ * foundation is meant to be portable, and a wire name in a component is a
26
+ * dependency on one backend's spelling. So the ops vocabulary stops here, and a
27
+ * component says `update(id, data)`.
28
+ *
29
+ * ## What this absorbs, and the one thing it deliberately does not
30
+ *
31
+ * Preconditions ride from the client's ledger, the response is absorbed, and a
32
+ * successful write **drops the cached reads of this Model** so a list a component is
33
+ * showing reflects what just happened. That last part is the difference between a
34
+ * writer and a fetch call: without it every caller hand-rolls invalidation, and
35
+ * most get it wrong in the same way.
36
+ *
37
+ * ⛔ **A conflict is surfaced, never resolved.** `conflict` is set, the ledger has
38
+ * already rebased onto the server's current token, and the *next* attempt will be
39
+ * guarded by truth — but this hook will not retry, because a retry succeeds by
40
+ * overwriting a change nobody looked at. What to do about someone else's edit is
41
+ * the application's question, and it is usually "tell the person".
42
+ *
43
+ * @param {{ schema: string, uuid: string } | null} target
44
+ * @returns {{
45
+ * create: Function, update: Function, remove: Function, move: Function,
46
+ * status: 'idle'|'saving'|'error', error: Error|null, conflict: Error|null,
47
+ * enabled: boolean, reset: Function
48
+ * }}
49
+ */
50
+ export function useEntityWriter(target) {
51
+ const client = getClient()
52
+ const [state, setState] = useState({ status: IDLE, error: null, conflict: null })
53
+ // A write in flight must not be reported by a later render of a stale closure.
54
+ const seq = useRef(0)
55
+
56
+ const schema = target?.schema ?? null
57
+ const uuid = target?.uuid ?? null
58
+ const enabled = !!(client && client.enabled && uuid)
59
+
60
+ const send = useCallback(
61
+ async (ops) => {
62
+ if (!enabled) throw ApiError.disabled()
63
+ const mine = (seq.current += 1)
64
+ setState({ status: SAVING, error: null, conflict: null })
65
+ try {
66
+ const result = await client.writeItems({ schema, uuid, ops })
67
+ // Only this Model's reads — a write to one Model says nothing about another,
68
+ // and sweeping wider would refetch pages the user is looking at for nothing.
69
+ client.invalidate((spec) => spec?.schema === schema)
70
+ if (seq.current === mine) setState({ status: IDLE, error: null, conflict: null })
71
+ return result
72
+ } catch (err) {
73
+ const conflict = err instanceof ApiError && err.status === 409 ? err : null
74
+ if (seq.current === mine) setState({ status: ERROR, error: err, conflict })
75
+ throw err
76
+ }
77
+ },
78
+ [client, enabled, schema, uuid],
79
+ )
80
+
81
+ const api = useMemo(
82
+ () => ({
83
+ /**
84
+ * Append an item to a section. Tokenless by design — there is no existing item
85
+ * to guard. `position` and `parent` are the server's ordering vocabulary,
86
+ * passed through rather than turned into an order number here.
87
+ *
88
+ * ⛔ `section` is REQUIRED and refused when missing, because getting it wrong
89
+ * fails SILENTLY: an entity has several sections, the item lands in whichever
90
+ * one the server defaults to, and every rule the author declared on the
91
+ * intended section — `append_only` above all — is quietly not in force. The
92
+ * write succeeds, the data looks present, and the guarantee is gone.
93
+ *
94
+ * @param {object} data - the item's content
95
+ * @param {object} opts
96
+ * @param {string} opts.section - which section of the entity this belongs to
97
+ * @param {string|number} [opts.parent] - a parent item, for nested sections
98
+ * @param {'first'|'last'|{after: string}} [opts.position]
99
+ */
100
+ create: (data, { section, parent, position } = {}) => {
101
+ if (!section) {
102
+ return Promise.reject(
103
+ new ApiError({
104
+ status: 0,
105
+ title: 'No Section',
106
+ detail: 'create needs a section — an item with no section lands outside the rules declared for it',
107
+ kind: 'invalid',
108
+ }),
109
+ )
110
+ }
111
+ return send({
112
+ kind: OP.create,
113
+ [FIELD.section]: section,
114
+ data,
115
+ ...(parent != null ? { [FIELD.parent]: parent } : {}),
116
+ ...(position != null ? { position } : {}),
117
+ })
118
+ },
119
+ /** Replace an item's data. ⚠️ Whole-data replace — round-trip what you do not edit. */
120
+ update: (itemId, data) => send({ kind: OP.update, [FIELD.item]: itemId, data }),
121
+ /** Delete one item. */
122
+ remove: (itemId) => send({ kind: OP.delete, [FIELD.item]: itemId }),
123
+ /**
124
+ * Reposition an item — `'first' | 'last' | { after: <itemId> }`.
125
+ *
126
+ * ⛔ The client never computes an order number. Ordering is the server's, and
127
+ * two clients arranging the same list from local sequence numbers is how a
128
+ * list ends up in an order neither of them chose.
129
+ */
130
+ move: (itemId, position) => send({ kind: OP.move, [FIELD.item]: itemId, position }),
131
+ /** Send several ops as ONE transaction — all of them land, or none do. */
132
+ batch: (ops) => send(ops),
133
+ reset: () => setState({ status: IDLE, error: null, conflict: null }),
134
+ }),
135
+ [send],
136
+ )
137
+
138
+ return { ...api, ...state, enabled }
139
+ }
140
+
141
+ export default useEntityWriter
@@ -0,0 +1,97 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
2
+ import { getClient } from '../client.js'
3
+
4
+ const noSubscribe = () => () => {}
5
+ const noSnapshot = () => null
6
+ const NONE = Object.freeze([])
7
+ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, hasMore: false, error: null })
8
+
9
+ /**
10
+ * The entities of a Model the viewer may see.
11
+ *
12
+ * ```jsx
13
+ * const { status, records } = useRecords({ schema: '@/session' })
14
+ * if (status === 'absent') return <StaticProgramme /> // no backend — render the site's own content
15
+ * if (status === 'ready' && records.length === 0) return <Empty />
16
+ * ```
17
+ *
18
+ * ## ⭐ `absent` and an empty `ready` are DIFFERENT, and conflating them is the bug
19
+ *
20
+ * `absent` means **there is no live source** — a site with no service-provider
21
+ * backend, which is the ordinary standalone case and not a failure. `ready` with
22
+ * `records: []` means **the source answered, and there is nothing there.**
23
+ *
24
+ * A component renders its own static content for the first and an empty state for
25
+ * the second, and they are not interchangeable: telling a visitor "no sessions yet"
26
+ * because the site has no backend is wrong in the same direction as the backend bug
27
+ * that once answered a lapsed session with an empty list — it reports absence of
28
+ * *access* as absence of *content*.
29
+ *
30
+ * Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
31
+ * is now looking, and a write through `useEntityWriter` drops it.
32
+ *
33
+ * @param {{ schema: string, scope?: string, limit?: number, offset?: number, all?: boolean } | null} query
34
+ * pass null to skip
35
+ * @returns {{ status: string, records: object[], matched: number, hasMore: boolean, error: Error|null, refresh: Function }}
36
+ */
37
+ export function useRecords(query) {
38
+ const client = getClient()
39
+ const website = client?.website ?? null
40
+ const store = website?.dataStore ?? null
41
+
42
+ // Re-key on a viewer change: what the viewer may see is part of the answer.
43
+ useSyncExternalStore(
44
+ client ? client.subscribe : noSubscribe,
45
+ client ? () => client.session : noSnapshot,
46
+ client ? () => client.session : noSnapshot,
47
+ )
48
+
49
+ const active = !!(client && client.enabled && store && query && query.schema)
50
+ const spec = active
51
+ ? {
52
+ endpoint: '/entities',
53
+ schema: query.schema,
54
+ scope: query.scope,
55
+ limit: query.limit,
56
+ offset: query.offset,
57
+ all: query.all,
58
+ }
59
+ : null
60
+ const key = spec ? client.cacheKey(spec) : null
61
+
62
+ const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
63
+ const entry = useSyncExternalStore(
64
+ subscribe,
65
+ () => (key ? store.get(key) : null),
66
+ () => (key ? store.get(key) : null),
67
+ )
68
+
69
+ const [error, setError] = useState(null)
70
+ const [attempt, setAttempt] = useState(0)
71
+
72
+ useEffect(() => {
73
+ if (!key || entry) return undefined
74
+ let live = true
75
+ setError(null)
76
+ client.load(key, () => client.listEntities(query), spec).catch((err) => {
77
+ if (live) setError(err)
78
+ })
79
+ return () => {
80
+ live = false
81
+ }
82
+ // `query` is read through `key`, which already encodes every part of it.
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [client, key, entry, attempt])
85
+
86
+ const refresh = useCallback(() => {
87
+ if (key && store) store.delete(key)
88
+ setAttempt((n) => n + 1)
89
+ }, [store, key])
90
+
91
+ if (!active) return { ...DISABLED, refresh }
92
+ if (entry) return { status: 'ready', ...entry.data, error: null, refresh }
93
+ if (error) return { status: 'error', records: NONE, matched: 0, hasMore: false, error, refresh }
94
+ return { status: 'loading', records: NONE, matched: 0, hasMore: false, error: null, refresh }
95
+ }
96
+
97
+ export default useRecords
package/src/index.js CHANGED
@@ -22,6 +22,10 @@ export {
22
22
  requestPasswordReset,
23
23
  confirmPasswordReset,
24
24
  readEntity,
25
+ listEntities,
26
+ writeItems,
27
+ createEntity,
28
+ deleteEntity,
25
29
  ApiError,
26
30
  Ledger,
27
31
  } from './client.js'
@@ -31,4 +35,6 @@ export { useSignIn } from './hooks/useSignIn.js'
31
35
  export { useSignUp } from './hooks/useSignUp.js'
32
36
  export { usePasswordReset } from './hooks/usePasswordReset.js'
33
37
  export { useEntity } from './hooks/useEntity.js'
38
+ export { useRecords } from './hooks/useRecords.js'
39
+ export { useEntityWriter } from './hooks/useEntityWriter.js'
34
40
  export { SignedIn, SignedOut } from './components/gates.js'
package/src/ledger.js CHANGED
@@ -2,29 +2,55 @@
2
2
  * The concurrency ledger — the last-seen `item_updated_at` per item, and the
3
3
  * stamping of `if_unmodified_since` onto the ops that need one.
4
4
  *
5
- * The backend guards writes at the item grain: `update`, `delete` and `move`
6
- * each carry the target item's last-seen `updated_at`; `create` carries none.
7
- * A mismatch is a `409` whose `current_updated_at` extension names the item's
8
- * current token, and every write response carries `item_updated_at` — the
9
- * next precondition to chain forward. Three sources, one token.
5
+ * The backend guards writes at the item grain: `update`, `delete` and `move` each
6
+ * carry the target item's last-seen `updated_at`; `create` carries none. A
7
+ * mismatch is a `409` whose `current_updated_at` extension names the item's
8
+ * current token, and every write response carries `item_updated_at` — the next
9
+ * precondition to chain forward. Three sources, one token.
10
+ *
11
+ * ⭐ `move` IS IN SCOPE, and the reasoning that briefly removed it is kept here
12
+ * because it is the mistake this package invites. It was dropped on 2026-09-01 as
13
+ * "an editor concern — an app's order is a property of the query, sort by a
14
+ * field". **That is true of a MEMBER LIST and false of the apps this package
15
+ * exists for.** An LMS instructor authors a course whose lessons are a curriculum
16
+ * SEQUENCE: the order is authored, stored, and repositioned by hand.
17
+ *
18
+ * ⇒ The trap is generalising from the CONSUMING surface. These apps have two, and
19
+ * both are ours: members read and append (progress, submissions), while OPERATORS
20
+ * author the app's own content — full CRUD over developer-defined schemas,
21
+ * hierarchy included. [Diego, 2026-09-01.]
22
+ *
23
+ * ⚠️ UNVERIFIED ON OUR LANE: `move` and its server-managed `position` were read
24
+ * off the site-editor's route, which is not ours. Whether
25
+ * `POST /api/entities/{uuid}/items` offers `move`, and in what shape, is a
26
+ * measurement nobody has taken. `stamp()` needs no branch either way — it guards
27
+ * every non-`create` op — so this docstring is the only thing a finding moves.
10
28
  *
11
29
  * That is the single most reinventable thing on the wire, so it lives here
12
30
  * once, as a pure structure with no route knowledge. The writer that composes
13
31
  * the request is the next slice; it will `stamp()` before sending, `absorb()`
14
32
  * what comes back, and `rebase()` on a conflict.
15
33
  *
16
- * Two field names are read from responses the item's id and its
17
- * `item_updated_at` and the first of those is a reading of the design, not
18
- * yet a pinned wire fact. It is one constant.
34
+ * **Every field name here now comes from `./wire.js`, and that fixed a real
35
+ * defect rather than tidying one.** This module read an op's target as `op.item`
36
+ * and probed responses through a guess list, `['item', 'item_id', 'id']`. The wire
37
+ * field is `item_id`. So a writer composing a correct op would have handed
38
+ * `stamp()` something whose target it could not see — and `stamp()` returns an
39
+ * unguarded op when it cannot find one, **by design, because an item it has never
40
+ * seen is legitimately last-writer-wins.** The two behaviours are identical from
41
+ * here and opposite in effect: one is "no token known", the other is "the
42
+ * precondition was silently dropped from every write."
43
+ *
44
+ * ⇒ That is the argument for one home per name, in miniature. A guess list cannot
45
+ * fail loudly, because guessing is what it is for.
19
46
  */
20
47
 
21
- const ID_FIELDS = ['item', 'item_id', 'id']
48
+ import { FIELD, OP } from './wire.js'
22
49
 
50
+ /** An op's or a response's item id, by the one name the wire uses. */
23
51
  function itemIdOf(record) {
24
- for (const field of ID_FIELDS) {
25
- if (record?.[field] != null) return String(record[field])
26
- }
27
- return null
52
+ const id = record?.[FIELD.item]
53
+ return id == null ? null : String(id)
28
54
  }
29
55
 
30
56
  export class Ledger {
@@ -56,9 +82,11 @@ export class Ledger {
56
82
  * @returns {object} the op, with `if_unmodified_since` when known
57
83
  */
58
84
  stamp(op) {
59
- if (!op || op.kind === 'create' || op.item == null) return op
60
- const at = this.get(op.item)
61
- return at == null ? op : { ...op, if_unmodified_since: at }
85
+ if (!op || op.kind === OP.create) return op
86
+ const id = itemIdOf(op)
87
+ if (id == null) return op
88
+ const at = this.get(id)
89
+ return at == null ? op : { ...op, [FIELD.precondition]: at }
62
90
  }
63
91
 
64
92
  /**
@@ -75,9 +103,9 @@ export class Ledger {
75
103
  return
76
104
  }
77
105
  const id = itemIdOf(result)
78
- if (id == null || !('item_updated_at' in result)) return
79
- if (result.item_updated_at === null) this.forget(id)
80
- else this.note(id, result.item_updated_at)
106
+ if (id == null || !(FIELD.token in result)) return
107
+ if (result[FIELD.token] === null) this.forget(id)
108
+ else this.note(id, result[FIELD.token])
81
109
  }
82
110
 
83
111
  /**
@@ -89,7 +117,7 @@ export class Ledger {
89
117
  * @returns {boolean} whether a token was recorded
90
118
  */
91
119
  rebase(itemId, error) {
92
- const current = error?.extensions?.current_updated_at
120
+ const current = error?.extensions?.[FIELD.conflictToken]
93
121
  if (current == null) return false
94
122
  this.note(itemId, current)
95
123
  return true
@@ -0,0 +1,194 @@
1
+ import { AUTH, ROUTES, PARAM, LIST, FIELD } from '../wire.js'
2
+ import { MockStore } from './store.js'
3
+ import { DEFAULT_SEED } from './seed.js'
4
+
5
+ /**
6
+ * A mock service-provider backend for local development.
7
+ *
8
+ * ```js
9
+ * import { createMockBackend } from '@uniweb/api/mock'
10
+ * const mock = createMockBackend({ seed })
11
+ * const response = await mock.fetch(request) // web-standard in, web-standard out
12
+ * ```
13
+ *
14
+ * ## ⭐ Why this lives in `@uniweb/api` and not in a package of its own
15
+ *
16
+ * Its entire value is **fidelity to what this client expects**, and the cheapest
17
+ * way to guarantee that is to make drift impossible: it is built from the same
18
+ * `../wire.js` the client reads, so a route or field name cannot disagree with the
19
+ * caller — they are the same constant. A separate package would need a version
20
+ * matrix nobody maintains, and would be wrong quietly.
21
+ *
22
+ * ⛔ **It ships Node code, and the browser must never reach it.** That is why this
23
+ * is a separate export (`@uniweb/api/mock`), never imported by `index.js` or
24
+ * `client.js`, and `tests/environment.test.js` walks the import graph from the
25
+ * browser entries to keep it that way.
26
+ *
27
+ * ## What it is, and what it is not
28
+ *
29
+ * ⭐ **It is the executable statement of what this package pins.** Backend asked
30
+ * *"tell us what you pin, and we will treat it as a contract"* — this is that
31
+ * answer in a form you can run. Where `../wire.js` marks a shape ASSUMED, this
32
+ * server implements the assumption, so pointing the same suite at a real `uniwebd`
33
+ * measures the delta instead of arguing about it.
34
+ *
35
+ * ⛔ **It is not a model of the real backend, and no doc may cite it as one.** It
36
+ * answers what this client asks. A behaviour it happens to have is evidence about
37
+ * this mock and nothing else.
38
+ *
39
+ * @param {object} [options]
40
+ * @param {object} [options.seed] - accounts, schemas and entities to start from
41
+ * @param {string} [options.prefix] - the path the API is mounted under (default `/api`)
42
+ * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore }}
43
+ */
44
+ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api' } = {}) {
45
+ const store = new MockStore(seed)
46
+
47
+ const json = (status, body) =>
48
+ new Response(body === undefined ? null : JSON.stringify(body), {
49
+ status,
50
+ headers: { 'content-type': 'application/json' },
51
+ })
52
+
53
+ /**
54
+ * A refusal, in problem+JSON. ⚠️ Extension members ride at the TOP LEVEL of the
55
+ * document — that is what RFC 7807 says and what `ApiError.fromResponse` reads.
56
+ * Nesting them under `extensions` is a mistake that looks right, and one this
57
+ * package's own tests made before they were corrected against the parser.
58
+ */
59
+ const problem = ({ status = 400, title = 'Error', detail, ...extensions }) =>
60
+ json(status, { status, title, ...(detail ? { detail } : {}), ...extensions })
61
+
62
+ const unauthorized = () =>
63
+ problem({ status: 401, title: 'Unauthorized', detail: 'sign in to continue' })
64
+
65
+ async function body(request) {
66
+ try {
67
+ return await request.json()
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ async function route(request) {
74
+ const url = new URL(request.url)
75
+ const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname
76
+ const q = url.searchParams
77
+ const method = request.method.toUpperCase()
78
+
79
+ // ── Identity ────────────────────────────────────────────────────────────
80
+ if (path === AUTH.me) {
81
+ const viewer = store.viewer()
82
+ return viewer ? json(200, viewer) : unauthorized()
83
+ }
84
+ if (path === AUTH.login && method === 'POST') {
85
+ const fields = (await body(request)) || {}
86
+ const viewer = store.signIn(fields.username, fields.password)
87
+ return viewer ? json(200, viewer) : problem({ status: 401, title: 'Unauthorized', detail: 'wrong username or password' })
88
+ }
89
+ if (path === AUTH.logout && method === 'POST') {
90
+ store.signOut()
91
+ return new Response(null, { status: 204 })
92
+ }
93
+ if (path === AUTH.register && method === 'POST') {
94
+ const fields = (await body(request)) || {}
95
+ const account = store.register(fields)
96
+ return account
97
+ ? json(200, { account: { uuid: account.uuid, username: account.username, handle: account.handle } })
98
+ : problem({ status: 409, title: 'Conflict', detail: 'that username is taken' })
99
+ }
100
+ if (path === AUTH.resetRequest && method === 'POST') return new Response(null, { status: 204 })
101
+ if (path === AUTH.resetConfirm && method === 'POST') return new Response(null, { status: 204 })
102
+ if (path === AUTH.challenge && method === 'POST') {
103
+ return problem({ status: 400, title: 'Validation', detail: 'this mock issues no challenge' })
104
+ }
105
+
106
+ // ── Entities ────────────────────────────────────────────────────────────
107
+ // ⭐ Everything below refuses an anonymous caller. That mirrors the real
108
+ // route's session invariant, and it is the half a mock is tempted to skip —
109
+ // a mock that answered anonymously would make every gate in the app look
110
+ // like it worked.
111
+ if (path.startsWith(ROUTES.list())) {
112
+ if (!store.account) return unauthorized()
113
+
114
+ const rest = path.slice(ROUTES.list().length)
115
+ const model = q.get(PARAM.model)
116
+
117
+ if (rest === '' && method === 'GET') {
118
+ if (!model) return problem({ title: 'Validation', detail: `Missing required parameter: ${PARAM.model}` })
119
+ const all = q.get(PARAM.paginate) === 'false'
120
+ const limit = q.has(PARAM.limit) ? Number(q.get(PARAM.limit)) : undefined
121
+ const offset = q.has(PARAM.offset) ? Number(q.get(PARAM.offset)) : undefined
122
+ return json(200, store.list({ model, limit, offset, all }))
123
+ }
124
+
125
+ if (rest === '' && method === 'POST') {
126
+ if (!model) return problem({ title: 'Validation', detail: `Missing required parameter: ${PARAM.model}` })
127
+ if (!store.mayCreate(model)) {
128
+ return problem({ status: 403, title: 'Denied', detail: `not permitted to create '${model}'` })
129
+ }
130
+ return json(200, store.create(model, (await body(request)) || {}))
131
+ }
132
+
133
+ if (rest === '/delete' && method === 'POST') {
134
+ const fields = (await body(request)) || {}
135
+ let deleted = 0
136
+ for (const uuid of fields.uuids || []) if (store.remove(uuid)) deleted += 1
137
+ return json(200, { deleted })
138
+ }
139
+
140
+ if (rest === '/batch' && method === 'POST') {
141
+ const fields = (await body(request)) || {}
142
+ const found = (fields.uuids || []).map((u) => store.read(u)).filter(Boolean)
143
+ return json(200, { [LIST.records]: found, [LIST.matched]: found.length })
144
+ }
145
+
146
+ const items = rest.match(/^\/([^/]+)\/items$/)
147
+ if (items && method === 'POST') {
148
+ const entity = store.entities.get(decodeURIComponent(items[1]))
149
+ if (!entity) return problem({ status: 404, title: 'NotFound', kind: 'entity' })
150
+ const payload = await body(request)
151
+ const ops = Array.isArray(payload) ? payload : [payload]
152
+ const outcome = store.applyOps(entity, ops)
153
+ if (!outcome.ok) return problem(outcome.problem)
154
+ // One op in, one result out; a batch reports per-op results. Matching the
155
+ // request's shape is what lets the ledger absorb either without branching.
156
+ return json(200, Array.isArray(payload) ? { results: outcome.results } : outcome.results[0])
157
+ }
158
+
159
+ const one = rest.match(/^\/([^/]+)$/)
160
+ if (one) {
161
+ const uuid = decodeURIComponent(one[1])
162
+ if (method === 'GET') {
163
+ const entity = store.read(uuid)
164
+ // ⭐ One word for not-found and not-permitted, by the real design: a
165
+ // component renders its paywall on it and never says "deleted".
166
+ return entity ? json(200, entity) : problem({ status: 404, title: 'NotFound', kind: 'entity' })
167
+ }
168
+ if (method === 'DELETE') {
169
+ return store.remove(uuid)
170
+ ? new Response(null, { status: 204 })
171
+ : problem({ status: 404, title: 'NotFound', kind: 'entity' })
172
+ }
173
+ }
174
+ }
175
+
176
+ return problem({ status: 404, title: 'NotFound', detail: `no route for ${method} ${path}` })
177
+ }
178
+
179
+ return {
180
+ store,
181
+ async fetch(request) {
182
+ try {
183
+ return await route(request)
184
+ } catch (err) {
185
+ // A mock that throws leaves the caller staring at a network error and
186
+ // blaming their own code. Answer, and say it was us.
187
+ return problem({ status: 500, title: 'MockFailure', detail: err?.message || 'the mock threw' })
188
+ }
189
+ },
190
+ }
191
+ }
192
+
193
+ export { MockStore } from './store.js'
194
+ export { DEFAULT_SEED } from './seed.js'
@@ -0,0 +1,106 @@
1
+ import { createServer } from 'node:http'
2
+
3
+ /**
4
+ * Node adapters for the mock — the only module in this package that imports a
5
+ * `node:` builtin, which is why it is a leaf nothing else re-exports.
6
+ *
7
+ * Two ways to run the same handler, and the choice is a real one:
8
+ *
9
+ * - **`middleware(mock)`** — mount it inside a dev server you already run, so the
10
+ * API is **same-origin** with the site. Cookies and `credentials: 'same-origin'`
11
+ * just work, there is no preflight, and — the part that matters — the site's
12
+ * config is identical in development and in production, because a real
13
+ * deployment serves this API on the site's own origin too.
14
+ * - **`serve(mock, { port })`** — a standalone server on its own port, for a
15
+ * frontend that is not a Uniweb site, or for proxying to. ⚠️ Reaching it
16
+ * cross-origin exercises CORS and third-party-cookie rules that **production does
17
+ * not have**, so a problem found that way may not be a real one. Prefer the
18
+ * middleware, or proxy to this from the dev server.
19
+ */
20
+
21
+ /** Node's IncomingMessage → a web Request. */
22
+ async function toRequest(req, origin = 'http://localhost') {
23
+ const url = new URL(req.url, origin)
24
+ const init = { method: req.method, headers: req.headers }
25
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
26
+ const chunks = []
27
+ for await (const chunk of req) chunks.push(chunk)
28
+ if (chunks.length) init.body = Buffer.concat(chunks)
29
+ }
30
+ return new Request(url, init)
31
+ }
32
+
33
+ /** A web Response → Node's ServerResponse. */
34
+ async function send(response, res) {
35
+ res.statusCode = response.status
36
+ response.headers.forEach((value, key) => res.setHeader(key, value))
37
+ const text = await response.text()
38
+ res.end(text || undefined)
39
+ }
40
+
41
+ /**
42
+ * Connect-style middleware — what Vite's `configureServer` takes.
43
+ *
44
+ * ```js
45
+ * // vite.config.js
46
+ * import { createMockBackend } from '@uniweb/api/mock'
47
+ * import { middleware } from '@uniweb/api/mock/node'
48
+ *
49
+ * const mock = createMockBackend()
50
+ * export default { plugins: [{ name: 'mock-api', configureServer: (s) => s.middlewares.use(middleware(mock)) }] }
51
+ * ```
52
+ *
53
+ * @param {{ fetch: Function }} mock
54
+ * @param {object} [options]
55
+ * @param {string} [options.prefix='/_api'] - paths outside it are passed straight through
56
+ */
57
+ export function middleware(mock, { prefix = '/_api' } = {}) {
58
+ return (req, res, next) => {
59
+ if (!req.url || !req.url.startsWith(prefix)) return next()
60
+ // Strip the mount point, so the handler sees the API's own paths and the
61
+ // deployment's choice of prefix stays the deployment's.
62
+ const inner = { ...req, url: req.url.slice(prefix.length) || '/', method: req.method, headers: req.headers }
63
+ Object.setPrototypeOf(inner, Object.getPrototypeOf(req))
64
+ toRequest(inner)
65
+ .then((request) => mock.fetch(request))
66
+ .then((response) => send(response, res))
67
+ .catch((err) => {
68
+ res.statusCode = 500
69
+ res.end(JSON.stringify({ status: 500, title: 'MockFailure', detail: err?.message }))
70
+ })
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ /**
76
+ * A standalone server. Resolves once listening; call `close()` to stop.
77
+ *
78
+ * @param {{ fetch: Function }} mock
79
+ * @param {object} [options]
80
+ * @param {number} [options.port=8787]
81
+ * @param {string} [options.prefix='']
82
+ * @returns {Promise<{ port: number, url: string, close: () => Promise<void> }>}
83
+ */
84
+ export function serve(mock, { port = 8787, prefix = '' } = {}) {
85
+ const server = createServer((req, res) => {
86
+ const url = prefix && req.url?.startsWith(prefix) ? req.url.slice(prefix.length) || '/' : req.url
87
+ toRequest({ ...req, url, [Symbol.asyncIterator]: () => req[Symbol.asyncIterator]() })
88
+ .then((request) => mock.fetch(request))
89
+ .then((response) => send(response, res))
90
+ .catch((err) => {
91
+ res.statusCode = 500
92
+ res.end(JSON.stringify({ status: 500, title: 'MockFailure', detail: err?.message }))
93
+ })
94
+ })
95
+ return new Promise((resolve, reject) => {
96
+ server.once('error', reject)
97
+ server.listen(port, () => {
98
+ const actual = server.address().port
99
+ resolve({
100
+ port: actual,
101
+ url: `http://localhost:${actual}${prefix}`,
102
+ close: () => new Promise((done) => server.close(done)),
103
+ })
104
+ })
105
+ })
106
+ }