@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.
- package/README.md +125 -45
- package/bin/uniweb-api-mock.js +34 -0
- package/package.json +10 -3
- package/src/client.js +229 -16
- package/src/hooks/useEntity.js +1 -1
- package/src/hooks/useEntityWriter.js +141 -0
- package/src/hooks/useRecords.js +97 -0
- package/src/index.js +6 -0
- package/src/ledger.js +48 -20
- package/src/mock/index.js +194 -0
- package/src/mock/node.js +106 -0
- package/src/mock/seed.js +51 -0
- package/src/mock/store.js +288 -0
- package/src/wire.js +243 -0
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* current token, and every write response carries `item_updated_at` — the
|
|
9
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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 ===
|
|
60
|
-
const
|
|
61
|
-
|
|
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 || !(
|
|
79
|
-
if (result.
|
|
80
|
-
else this.note(id, result.
|
|
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?.
|
|
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'
|
package/src/mock/node.js
ADDED
|
@@ -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
|
+
}
|