@uniweb/api 0.6.0 → 0.6.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/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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
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 this.request('GET', ROUTES.read(uuid), {
562
- query: { [PARAM.model]: schema, [PARAM.via]: via, ...this._localeQuery() },
563
- signal,
564
- })
565
- this.ledger.observe(entity?.[READ.hydrated]?.[READ.items], mark)
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 for the Models the viewer may create, and by the
655
- * site's operator. Anywhere else this cannot resolve a name, and says so — pass
656
- * the section's numeric id instead (an item's `section_id` on a read).
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
@@ -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 — `entity.hydrated.entity.brief` its summary,
20
- * and `entity.can_edit` whether this viewer may write to it.
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/store.js CHANGED
@@ -85,8 +85,21 @@ function canonical(value) {
85
85
  const isMulti = (section) => section?.multiple === true || section?.kind === 'multi'
86
86
  const shortName = (model) => String(model).split('/').pop() || String(model)
87
87
 
88
- /** A `{ locale: value }` map — a plain object, never an array (a localized list's value). */
89
- const isLocaleMap = (value) => value != null && typeof value === 'object' && !Array.isArray(value)
88
+ /**
89
+ * A `{ locale: value }` map — a plain object, never an array (a localized list's
90
+ * value), and never the value of a field whose OWN type is an object.
91
+ *
92
+ * ⛔ A `json` field's value is a plain object too — a richtext body is a ProseMirror
93
+ * document — and read as a map it has no `en`, so the body was DELETED from every
94
+ * read that carried `?locale=`, which the client sends on every browser read.
95
+ * Measured 2026-09-24 on a lesson whose `content` section became declared. The
96
+ * write checker (`schema-shape.js`) takes any object in such a field as THE value,
97
+ * so the projection reads it the same way: for these types the map shape is not
98
+ * knowable, and a value is a value.
99
+ */
100
+ const OBJECT_TYPED = new Set(['json', 'object', 'group'])
101
+ const isLocaleMap = (field, value) =>
102
+ value != null && typeof value === 'object' && !Array.isArray(value) && !OBJECT_TYPED.has(field?.type)
90
103
 
91
104
  export class MockStore {
92
105
  /**
@@ -497,13 +510,14 @@ export class MockStore {
497
510
  * an array, and an array is a value, not a map of them: read as a map it has no
498
511
  * `en`, and the field was DELETED from every read that carried `?locale=` — which
499
512
  * the client sends on every browser read. Measured 2026-09-22 on a course brief
500
- * whose `outcomes` vanished, and on quiz questions whose `options` did.
513
+ * whose `outcomes` vanished, and on quiz questions whose `options` did. The same
514
+ * for a `json` field's document (`isLocaleMap`), measured 2026-09-24.
501
515
  */
502
516
  project(data, section, locales) {
503
517
  if (!locales?.length || !section?.fields || !data || typeof data !== 'object') return data
504
518
  const out = { ...data }
505
519
  for (const [key, field] of Object.entries(section.fields)) {
506
- if (!field?.localized || !isLocaleMap(out[key])) continue
520
+ if (!field?.localized || !isLocaleMap(field, out[key])) continue
507
521
  const hit = locales.find((l) => out[key][l] != null)
508
522
  if (hit) out[key] = out[key][hit]
509
523
  else delete out[key]