@uniweb/api 0.5.1 → 0.6.1
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 +8 -2
- package/package.json +1 -1
- package/src/client.js +50 -8
- package/src/hooks/useEntity.js +4 -2
- package/src/mock/index.js +4 -10
- package/src/mock/seed.js +7 -8
- package/src/mock/store.js +17 -10
package/README.md
CHANGED
|
@@ -96,11 +96,18 @@ full (`limit`, default 50); `matched` counts the records in this answer, not a t
|
|
|
96
96
|
|
|
97
97
|
```jsx
|
|
98
98
|
const { status, entity } = useEntity({ schema: '@acme/lesson', uuid, via: course.uuid })
|
|
99
|
-
// entity.hydrated.items — the content: { id, section_id, data, … } each
|
|
99
|
+
// entity.hydrated.items — the content: { id, section, section_id, data, … } each
|
|
100
100
|
// entity.hydrated.entity.brief — the summary
|
|
101
101
|
// entity.can_edit — whether this viewer may write to it
|
|
102
|
+
|
|
103
|
+
const sessions = entity.hydrated.items.filter((item) => item.section === 'sessions')
|
|
102
104
|
```
|
|
103
105
|
|
|
106
|
+
`item.section` is the section's name, the word a write takes (`parent/child` for a nested
|
|
107
|
+
section). The package resolves it from the Model's definition, so a component never
|
|
108
|
+
handles a section id. `item.id` is what `useEntityWriter` takes to update, move or remove
|
|
109
|
+
the item.
|
|
110
|
+
|
|
104
111
|
Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
|
|
105
112
|
or sign-in prompt on it and never say "deleted".
|
|
106
113
|
|
|
@@ -179,7 +186,6 @@ export default createMockBackend({
|
|
|
179
186
|
],
|
|
180
187
|
schemas: {
|
|
181
188
|
'@acme/track': {
|
|
182
|
-
creatable_by: 'unit_members', // the operator only; 'any_user' is the default
|
|
183
189
|
sections: {
|
|
184
190
|
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
|
|
185
191
|
sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -103,6 +103,32 @@ function viewerWorkspace(ws) {
|
|
|
103
103
|
return Object.freeze({ unitUuid: ws.unit_uuid ?? null, handle: ws.handle ?? null })
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Label each item with its section's NAME — `section`, the word a write takes — from
|
|
108
|
+
* a Model's sections (`ApiClient.sections`): `sessions`, or `parent/child` for a
|
|
109
|
+
* nested section. An item whose section the definition does not name stays as it is.
|
|
110
|
+
*
|
|
111
|
+
* @param {object[]|undefined} items - `hydrated.items`, labelled in place
|
|
112
|
+
* @param {Array<{ id: number, name: string, parent: number|null }>|null} sections
|
|
113
|
+
*/
|
|
114
|
+
function labelSections(items, sections) {
|
|
115
|
+
if (!Array.isArray(items) || !Array.isArray(sections) || !sections.length) return
|
|
116
|
+
const byId = new Map(sections.map((s) => [s.id, s]))
|
|
117
|
+
const pathOf = (section) => {
|
|
118
|
+
const names = []
|
|
119
|
+
const seen = new Set()
|
|
120
|
+
for (let at = section; at && !seen.has(at.id); at = byId.get(at.parent)) {
|
|
121
|
+
seen.add(at.id)
|
|
122
|
+
names.unshift(at.name)
|
|
123
|
+
}
|
|
124
|
+
return names.join('/')
|
|
125
|
+
}
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
const section = item && byId.get(item[READ.itemSection])
|
|
128
|
+
if (section) item[CREATE.sectionName] = pathOf(section)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
106
132
|
export class ApiClient {
|
|
107
133
|
/**
|
|
108
134
|
* @param {object} uniweb - the page's `Uniweb` singleton
|
|
@@ -539,6 +565,13 @@ export class ApiClient {
|
|
|
539
565
|
* and `hydrated.entity.brief` is its summary. `can_edit` is the write gate's
|
|
540
566
|
* own answer for this viewer.
|
|
541
567
|
*
|
|
568
|
+
* ⭐ **Each item also carries `section`, its section's NAME** — `sessions`, or a
|
|
569
|
+
* `parent/child` path for a nested one — the word a write takes, so a component
|
|
570
|
+
* never meets a section id in either direction. It is this package's label, not
|
|
571
|
+
* the backend's: resolved from the Model's definition, read beside the entity
|
|
572
|
+
* (once per viewer, and kept). When the definition cannot be read, items carry
|
|
573
|
+
* no `section` and the read still succeeds.
|
|
574
|
+
*
|
|
542
575
|
* ⭐ **The read seeds the concurrency ledger** with each item's `updated_at`, so
|
|
543
576
|
* the first edit of an item is guarded by the version the viewer was shown.
|
|
544
577
|
*
|
|
@@ -558,11 +591,18 @@ export class ApiClient {
|
|
|
558
591
|
if (!schema) throw ApiError.invalid('No Model', 'readEntity needs a schema — the backend reads an entity by its Model')
|
|
559
592
|
const mark = this.ledger.mark()
|
|
560
593
|
try {
|
|
561
|
-
const entity = await
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
594
|
+
const [entity, sections] = await Promise.all([
|
|
595
|
+
this.request('GET', ROUTES.read(uuid), {
|
|
596
|
+
query: { [PARAM.model]: schema, [PARAM.via]: via, ...this._localeQuery() },
|
|
597
|
+
signal,
|
|
598
|
+
}),
|
|
599
|
+
// A label, not a condition of the read: a definition this viewer cannot read
|
|
600
|
+
// leaves the items unlabelled.
|
|
601
|
+
this.sections(schema, signal).catch(() => null),
|
|
602
|
+
])
|
|
603
|
+
const items = entity?.[READ.hydrated]?.[READ.items]
|
|
604
|
+
this.ledger.observe(items, mark)
|
|
605
|
+
labelSections(items, sections)
|
|
566
606
|
return { status: 'ready', entity }
|
|
567
607
|
} catch (err) {
|
|
568
608
|
if (err instanceof ApiError && err.kind === 'absent') return { status: 'absent', entity: null }
|
|
@@ -651,9 +691,11 @@ export class ApiClient {
|
|
|
651
691
|
* section names; the item route after it takes ids, and ids differ from one
|
|
652
692
|
* backend to the next. So a name is resolved here, from the Model's definition.
|
|
653
693
|
*
|
|
654
|
-
* ⚠️ The definition is readable
|
|
655
|
-
*
|
|
656
|
-
* the
|
|
694
|
+
* ⚠️ The definition is readable by any signed-in viewer — Models are open, so every
|
|
695
|
+
* Model is one they may create (2026-09-23; until then only the Models a viewer
|
|
696
|
+
* could create, and the operator). Where it cannot be read this cannot resolve a
|
|
697
|
+
* name, and says so — pass the section's numeric id instead (an item's
|
|
698
|
+
* `section_id` on a read).
|
|
657
699
|
*
|
|
658
700
|
* @param {string} schema
|
|
659
701
|
* @param {string|number} section - a name, a `parent/child` path, or an id
|
package/src/hooks/useEntity.js
CHANGED
|
@@ -16,8 +16,10 @@ const DISABLED = Object.freeze({ status: 'absent', entity: null, error: null })
|
|
|
16
16
|
* ```
|
|
17
17
|
*
|
|
18
18
|
* The entity is the backend's answer: `entity.hydrated.items` is its content —
|
|
19
|
-
* `{ id, section_id, data, … }` each
|
|
20
|
-
*
|
|
19
|
+
* `{ id, section, section_id, data, … }` each, `section` being the section's NAME
|
|
20
|
+
* (the word a write takes; this package's label, see `readEntity`) —
|
|
21
|
+
* `entity.hydrated.entity.brief` its summary, and `entity.can_edit` whether this
|
|
22
|
+
* viewer may write to it.
|
|
21
23
|
*
|
|
22
24
|
* Cached in the site's data store under a key scoped to the viewer, so a
|
|
23
25
|
* sign-in or sign-out changes the key and the record is read again for who
|
package/src/mock/index.js
CHANGED
|
@@ -39,7 +39,7 @@ import { DEFAULT_SEED } from './seed.js'
|
|
|
39
39
|
* One operator (`operator: true` on a seeded account) and members; a member reads
|
|
40
40
|
* and writes their own entities and nothing of another's (unless the seed's
|
|
41
41
|
* `memberFloor` says the service lets them), the operator reads and writes all;
|
|
42
|
-
*
|
|
42
|
+
* anyone signed in may create entries of any Model (Models are open); sign-up
|
|
43
43
|
* leaves an account unverified until the link in `mock.outbox` is followed. It
|
|
44
44
|
* does not model entitlements behind `via`, nested sections, or second factors.
|
|
45
45
|
*
|
|
@@ -286,15 +286,9 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
286
286
|
}
|
|
287
287
|
const model = store.model(q.get(PARAM.model))
|
|
288
288
|
if (!model) return notFound('model', q.get(PARAM.model))
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
title: 'Forbidden',
|
|
293
|
-
detail: `use_model on model ${model.uuid} not permitted`,
|
|
294
|
-
op: 'use_model',
|
|
295
|
-
target: `model ${model.uuid}`,
|
|
296
|
-
})
|
|
297
|
-
}
|
|
289
|
+
// ⭐ No creation gate: Models are open (2026-09-23) — anyone signed in may create
|
|
290
|
+
// entries of any Model. Until then `creatable_by: unit_members` answered `403
|
|
291
|
+
// use_model` here for a member.
|
|
298
292
|
// Any other top-level key is ignored, exactly as the backend ignores it.
|
|
299
293
|
return answer(store.create(model, items.map((i) => ({ ...i, data: i.data ?? {} }))))
|
|
300
294
|
}
|
package/src/mock/seed.js
CHANGED
|
@@ -7,24 +7,24 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - **`accounts`** — `operator: true` marks the account that runs the site's
|
|
9
9
|
* service. Everyone else is a member. Seeded accounts are verified.
|
|
10
|
-
* - **`schemas`** — per Model: `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - **`schemas`** — per Model: `sections`, the Model's sections as the framework
|
|
11
|
+
* lowers them. With `sections`, writes are shape-checked. Models are open:
|
|
12
|
+
* anyone signed in may create entries of any of them.
|
|
13
13
|
* - **`entities`** — `uuid` must be a UUID, as it is on the wire; content is items,
|
|
14
14
|
* each naming its section. The entity's `brief` is derived from its brief
|
|
15
15
|
* section's item, as the backend derives it.
|
|
16
16
|
*/
|
|
17
17
|
export const DEFAULT_SEED = {
|
|
18
18
|
accounts: [
|
|
19
|
-
// The organiser runs the site,
|
|
19
|
+
// The organiser runs the site, and the programme's tracks are theirs.
|
|
20
20
|
{ username: 'organiser', password: 'organiser', operator: true },
|
|
21
|
-
// The attendee is a member
|
|
22
|
-
//
|
|
21
|
+
// The attendee is a member. They may make entries of their own — recording
|
|
22
|
+
// attending is the demo — and may not edit the organiser's programme: the entry
|
|
23
|
+
// decides, server-side, and that refusal is the other half of the demo.
|
|
23
24
|
{ username: 'attendee', password: 'attendee' },
|
|
24
25
|
],
|
|
25
26
|
schemas: {
|
|
26
27
|
'@/track': {
|
|
27
|
-
creatable_by: 'unit_members',
|
|
28
28
|
sections: {
|
|
29
29
|
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
|
|
30
30
|
sessions: {
|
|
@@ -36,7 +36,6 @@ export const DEFAULT_SEED = {
|
|
|
36
36
|
// Check-ins are insert-only: an attendee may record attending, and nobody —
|
|
37
37
|
// including them — may edit or remove it afterwards.
|
|
38
38
|
'@/attendance': {
|
|
39
|
-
creatable_by: 'any_user',
|
|
40
39
|
sections: {
|
|
41
40
|
attendance: { kind: 'single', brief: true, fields: { who: { type: 'string' } } },
|
|
42
41
|
checkins: { kind: 'multi', append_only: true, fields: { session: { type: 'string' }, at: { type: 'string' } } },
|
package/src/mock/store.js
CHANGED
|
@@ -94,7 +94,8 @@ export class MockStore {
|
|
|
94
94
|
* @param {object[]} [seed.accounts] - `{ username, password, email?, handle?, operator? }`.
|
|
95
95
|
* Seeded accounts are verified. (`units: [...]` non-empty, the older spelling,
|
|
96
96
|
* also marks the operator.)
|
|
97
|
-
* @param {object} [seed.schemas] - `{ '@scope/name': {
|
|
97
|
+
* @param {object} [seed.schemas] - `{ '@scope/name': { sections?, append_only?, migration_debt? } }`.
|
|
98
|
+
* ⛔ No `creatable_by` — retired 2026-09-23, Models are open; a seed carrying it is refused.
|
|
98
99
|
* `sections` is the LOWERED sections map (`{ [name]: { kind | multiple, brief, append_only, fields } }`)
|
|
99
100
|
* as the framework's normalizer produces it; with it, writes are shape-checked
|
|
100
101
|
* (`./schema-shape.js`). Without it the sections are inferred: a one-item brief
|
|
@@ -117,6 +118,13 @@ export class MockStore {
|
|
|
117
118
|
for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
|
|
118
119
|
|
|
119
120
|
this.schemas = seed.schemas || {}
|
|
121
|
+
for (const [name, decl] of Object.entries(this.schemas)) {
|
|
122
|
+
if (decl && (decl.creatable_by !== undefined || decl.creatableBy !== undefined)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[uniweb/api mock] ${name}: creatable_by is retired — Models are open: anyone signed in may create entries of any Model, and who may see or edit an entry is decided by the entry. Remove it.`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
120
128
|
const floors = seed.memberFloor != null && typeof seed.memberFloor === 'object' ? Object.values(seed.memberFloor) : [seed.memberFloor]
|
|
121
129
|
for (const floor of floors) {
|
|
122
130
|
if (floor != null && !['read', 'edit'].includes(floor)) {
|
|
@@ -269,7 +277,6 @@ export class MockStore {
|
|
|
269
277
|
id: (this.ids.model += 1),
|
|
270
278
|
uuid: newUuid(),
|
|
271
279
|
name,
|
|
272
|
-
creatable_by: decl?.creatable_by || 'any_user',
|
|
273
280
|
decl: decl || {},
|
|
274
281
|
sections: [],
|
|
275
282
|
}
|
|
@@ -314,14 +321,14 @@ export class MockStore {
|
|
|
314
321
|
return null
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
maySeeSchema(
|
|
324
|
-
return !!this.account
|
|
324
|
+
/**
|
|
325
|
+
* The definition is readable by anyone signed in. ⭐ Models are open (2026-09-23):
|
|
326
|
+
* anyone signed in may create entries of any Model, and what protects content is the
|
|
327
|
+
* entry — `mayEdit`, `mayRead` — never who may create. *(Until then a Model declaring
|
|
328
|
+
* `creatable_by: unit_members` was the operator's alone, and so was its definition.)*
|
|
329
|
+
*/
|
|
330
|
+
maySeeSchema() {
|
|
331
|
+
return !!this.account
|
|
325
332
|
}
|
|
326
333
|
|
|
327
334
|
/** `GET /models/@scope/name`, in the backend's shape. */
|