@uniweb/api 1.2.0 → 1.2.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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * A Model's definition, as the backend stores and answers it — lowered from a seed's
3
+ * declaration the way a registered schema is lowered (MEASURED 2026-10-14; `SCHEMA` in
4
+ * `../wire.js` is the shape).
5
+ *
6
+ * ⭐ **A field keeps what the backend keeps, and nothing else.** A key the backend's
7
+ * catalog does not hold for the field's kind never reaches a stored declaration — a
8
+ * schema carrying one is refused when it is registered — so the mock's definition
9
+ * leaves it out rather than show a form a rule no backend applies. The catalog, per
10
+ * kind, beside what every field may declare (`label`, `description`, `required`,
11
+ * `multiple`, `localized`, `compact`):
12
+ *
13
+ * | kind | its own keys |
14
+ * |---|---|
15
+ * | `string` · `text` | `max_length`, `languages`, `format` |
16
+ * | `int` | `min`, `max` |
17
+ * | `decimal` | `precision`, `scale`, `min`, `max` |
18
+ * | `date` | `format` — `year-month` or `year` |
19
+ * | `item_ref` | `options` (a schema's) → `section_uuid`; `entity_uuid` |
20
+ * | `entity_ref` | `model` (a schema's) → `model_uuid`; `model_uuids`, `contains`, `readable_with` |
21
+ * | `file` | `mime_types`, `max_size` |
22
+ * | `json` | `languages`, `format` |
23
+ * | `bool` · `datetime` · `secret` | none |
24
+ *
25
+ * And what moves: an `enum` becomes its section's `one_of` rule; `multiple: true` is
26
+ * `multi`; a nested section is a section of its own.
27
+ */
28
+
29
+ /** The keys a kind declares beyond the ones every field may (see the table above). */
30
+ const KIND_KEYS = {
31
+ string: ['max_length', 'languages', 'format'],
32
+ text: ['max_length', 'languages', 'format'],
33
+ int: ['min', 'max'],
34
+ decimal: ['precision', 'scale', 'min', 'max'],
35
+ date: ['format'],
36
+ item_ref: ['entity_uuid'],
37
+ entity_ref: ['model_uuids', 'contains', 'readable_with'],
38
+ file: ['mime_types', 'max_size'],
39
+ json: ['languages', 'format'],
40
+ }
41
+
42
+ /** Prose about a field — a plain string. */
43
+ const DOC_KEYS = ['label', 'description']
44
+
45
+ /** Modifiers any kind that supports them declares. */
46
+ const MODIFIER_KEYS = ['localized', 'compact']
47
+
48
+ /** A field's kind: its `type`, or a list's element type in the normalizer's `array` spelling. */
49
+ export function kindOf(field) {
50
+ if (field?.type === 'array') return field.items?.type ?? 'json'
51
+ return field?.type ?? 'json'
52
+ }
53
+
54
+ /** Is this field a list — `multiple: true`, or the normalizer's `type: 'array'`? */
55
+ export const isList = (field) => field?.multiple === true || field?.type === 'array'
56
+
57
+ /**
58
+ * A field as the definition answers it: `{ key, required, multi, type: { id: -1, name,
59
+ * kind, data } }`, `data` its stored declaration.
60
+ *
61
+ * @param {string} key
62
+ * @param {object} field - the seed's declaration
63
+ * @param {{ section?: string|null, model?: string|null }} pins - an `item_ref`'s section
64
+ * uuid and an `entity_ref`'s Model uuid, resolved by the caller
65
+ */
66
+ export function fieldRow(key, field, pins = {}) {
67
+ const kind = kindOf(field)
68
+ const data = { key, kind }
69
+ if (field?.required === true) data.required = true
70
+ if (isList(field)) data.multi = true
71
+ for (const k of DOC_KEYS) if (typeof field?.[k] === 'string') data[k] = field[k]
72
+ for (const k of MODIFIER_KEYS) if (field?.[k] !== undefined) data[k] = field[k]
73
+ for (const k of KIND_KEYS[kind] ?? []) if (field?.[k] !== undefined) data[k] = field[k]
74
+ if (kind === 'item_ref' && pins.section) data.section_uuid = pins.section
75
+ if (kind === 'entity_ref' && pins.model) data.model_uuid = pins.model
76
+ return {
77
+ key,
78
+ required: data.required === true,
79
+ multi: data.multi === true,
80
+ type: { id: -1, name: kind, kind, data },
81
+ }
82
+ }
83
+
84
+ /**
85
+ * A section's rules: its own (a bare array, or `{ rules: [...] }`), then a `one_of` for
86
+ * each field that declares an `enum`, in field order.
87
+ *
88
+ * @param {object|null} section - the seed's declaration
89
+ * @param {Object<string, object>|null} leaves - its fields, by key
90
+ */
91
+ export function rulesOf(section, leaves) {
92
+ const own = section?.constraints
93
+ const rules = Array.isArray(own) ? [...own] : Array.isArray(own?.rules) ? [...own.rules] : []
94
+ for (const [key, field] of Object.entries(leaves || {})) {
95
+ if (Array.isArray(field?.enum) && field.enum.length) rules.push({ kind: 'one_of', field: key, values: field.enum })
96
+ }
97
+ return rules
98
+ }
99
+
100
+ /** What a section's `other_data` holds: what the schema declares of the section. */
101
+ export function otherDataOf(section, { appendOnly = false, nests = false } = {}) {
102
+ const other = {}
103
+ for (const k of DOC_KEYS) if (typeof section?.[k] === 'string') other[k] = section[k]
104
+ if (appendOnly) other.append_only = true
105
+ if (nests) other.allow_self_nesting = true
106
+ if (typeof section?.date_field === 'string') other.date_field = section.date_field
107
+ return other
108
+ }
109
+
110
+ /**
111
+ * `options: '@scope/name/section/path'` — the Model named by its first two segments, and
112
+ * the section path after them — or null when it has the wrong shape.
113
+ */
114
+ export function parseOptions(options) {
115
+ if (typeof options !== 'string') return null
116
+ const parts = options.split('/')
117
+ if (parts.length < 3 || !parts[0].startsWith('@')) return null
118
+ return { model: `${parts[0]}/${parts[1]}`, path: parts.slice(2).join('/') }
119
+ }
package/src/mock/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AUTH, ENTITIES, MODELS, OFFERS, INVITATIONS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL, ELEVATION } from '../wire.js'
1
+ import { AUTH, ENTITIES, MODELS, SECTIONS, OFFERS, INVITATIONS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL, ELEVATION } from '../wire.js'
2
2
  import { MockStore, isUuid, refuse } from './store.js'
3
3
  import { DEFAULT_SEED } from './seed.js'
4
4
 
@@ -17,6 +17,24 @@ export function printMail(message, url) {
17
17
  */
18
18
  const ADMIN_PAGE = '/admin'
19
19
 
20
+ /**
21
+ * A page's cursor, opaque as the backend's is: hex of the JSON of where the page ended.
22
+ * Byte work by hand, so the mock stays free of Node's `Buffer`.
23
+ */
24
+ const encodeCursor = (key) =>
25
+ [...new TextEncoder().encode(JSON.stringify(key))].map((b) => b.toString(16).padStart(2, '0')).join('')
26
+
27
+ /** The key a cursor names, or null for one this mock did not hand out — "expired". */
28
+ function decodeCursor(token) {
29
+ if (!/^(?:[0-9a-f]{2})+$/.test(token)) return null
30
+ try {
31
+ const key = JSON.parse(new TextDecoder().decode(Uint8Array.from(token.match(/../g).map((h) => parseInt(h, 16)))))
32
+ return Array.isArray(key) && key.length === 5 && key.every(Number.isFinite) ? key : null
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
20
38
  const escapeHtml = (value) =>
21
39
  String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
22
40
 
@@ -53,7 +71,7 @@ function adminPage(account) {
53
71
  *
54
72
  * ## Where it answers
55
73
  *
56
- * Its routes are the API's own — `/auth/me`, `/entities` — under whatever the
74
+ * Its routes are the API's own — `/auth/me`, `/entities`, `/sections` — under whatever the
57
75
  * site's `backend` address is. It accepts a request with the site's `/_api` prefix
58
76
  * still on, and one with an extra `/api` after it (how clients before 0.4
59
77
  * composed URLs), so it answers the same whether or not the server mounting it
@@ -69,9 +87,11 @@ function adminPage(account) {
69
87
  * account the backend's administration admits, so its Admin link answers — with a
70
88
  * redirect to a page of the mock's own, since the administration itself is not
71
89
  * modelled. An offer, claimed or bound by confirming its address, leaves its
72
- * invitations waiting, each to answer once. It does not model entitlements behind
73
- * `via`, nested sections, second factors, password rules, an offer's grants, what
74
- * joining an organization gives, or an organization withdrawing an invitation.
90
+ * invitations waiting, each to answer once. A field that picks from a list stores an
91
+ * item's uuid, checked against its list, and the list's items are read a page at a time
92
+ * from `/sections/{uuid}/items`. It does not model entitlements behind `via`, second
93
+ * factors, password rules, an offer's grants, what joining an organization gives, or an
94
+ * organization withdrawing an invitation.
75
95
  *
76
96
  * ## The mail it sends
77
97
  *
@@ -352,18 +372,52 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
352
372
  return model && store.maySeeSchema(model) ? json(200, store.schemaOf(model)) : notFound('model', name)
353
373
  }
354
374
 
375
+ // ── A Section's items ────────────────────────────────────────────────────
376
+ const listed = path.startsWith(`${SECTIONS}/`) ? path.slice(SECTIONS.length).match(/^\/([^/]+)\/items$/) : null
377
+ if (listed && method === 'GET') {
378
+ const bad = unexpected(q, [PARAM.entity, PARAM.parent, PARAM.field, PARAM.q, PARAM.locale, PARAM.cursor, PARAM.limit])
379
+ if (bad) return bad
380
+ const limit = int(q, PARAM.limit, PAGE.size)
381
+ if (limit === undefined) return badRequest('invalid parameter value')
382
+ if (!store.account) return unauthorized()
383
+ const sectionUuid = listed[1]
384
+ if (!isUuid(sectionUuid)) return badRequest(`invalid section uuid: ${sectionUuid}`)
385
+ const token = q.get(PARAM.cursor) || ''
386
+ const after = token ? decodeCursor(token) : null
387
+ if (token && !after) return badRequest('cursor expired, restart pagination')
388
+ for (const name of [PARAM.entity, PARAM.parent]) {
389
+ const value = q.get(name)
390
+ if (value && !isUuid(value)) return badRequest(`invalid ${name} uuid: ${value}`)
391
+ }
392
+ const found = store.sectionByUuid(sectionUuid)
393
+ if (!found) return notFound('section', sectionUuid)
394
+ const outcome = store.sectionItems(found.model, found.section, {
395
+ entity: q.get(PARAM.entity) || null,
396
+ parent: q.get(PARAM.parent) || null,
397
+ field: q.get(PARAM.field) || '',
398
+ q: q.get(PARAM.q) || '',
399
+ locales: locales(q),
400
+ after,
401
+ limit: Math.min(Math.max(limit, 1), PAGE.max),
402
+ })
403
+ if (outcome.problem) return answer(outcome)
404
+ const { next_cursor: next, ...rest } = outcome.body
405
+ return json(200, { ...rest, next_cursor: next ? encodeCursor(next) : null })
406
+ }
407
+
355
408
  // ── Entities ────────────────────────────────────────────────────────────
356
409
  if (path === ENTITIES || path.startsWith(`${ENTITIES}/`)) {
357
410
  const rest = path.slice(ENTITIES.length)
358
411
 
359
412
  if (rest === '' && method === 'GET') {
360
- const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale, PARAM.includeDisabled])
413
+ // ⛔ No `include_disabled`: the backend refuses it, and lists a disabled entity like any other.
414
+ const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale])
361
415
  if (bad) return bad
362
416
  if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
363
417
  const limit = int(q, PARAM.limit, PAGE.size)
364
418
  const offset = int(q, PARAM.offset, 0)
365
419
  const paginate = bool(q, PARAM.paginate, true)
366
- if (limit === undefined || offset === undefined || paginate === undefined || bool(q, PARAM.includeDisabled, false) === undefined) {
420
+ if (limit === undefined || offset === undefined || paginate === undefined) {
367
421
  return badRequest('invalid parameter value')
368
422
  }
369
423
  const scope = q.get(PARAM.scope) || SCOPE.accessible
@@ -148,6 +148,20 @@ function listElement(field) {
148
148
  return null
149
149
  }
150
150
 
151
+ /**
152
+ * A section of the declaration by name — or by `parent/child` path for a nested one,
153
+ * declared as a field of `type: 'section'` inside its parent.
154
+ */
155
+ function sectionAt(sections, path) {
156
+ const [first, ...rest] = String(path).split('/')
157
+ let at = sections[first]
158
+ for (const name of rest) {
159
+ const next = at?.fields?.[name]
160
+ at = next?.type === 'section' ? next : undefined
161
+ }
162
+ return at
163
+ }
164
+
151
165
  /**
152
166
  * Is this section a multi section of the model's declaration?
153
167
  *
@@ -158,7 +172,7 @@ function listElement(field) {
158
172
  export function classifySection(decl, section) {
159
173
  const sections = decl?.sections
160
174
  if (!sections || typeof sections !== 'object') return null
161
- const found = sections[section]
175
+ const found = sectionAt(sections, section)
162
176
 
163
177
  // ⭐ The debt list is consulted FIRST and is an exact `(model, section)` match, so
164
178
  // it can excuse a declared section whose records diverge as well as an undeclared
@@ -225,8 +239,80 @@ function satisfiesType(type, value) {
225
239
  }
226
240
  }
227
241
 
242
+ /** The text kinds — what a `localized` value, a `max_length` and a search apply to. */
243
+ const TEXT_TYPES = new Set(['string', 'text', 'html', 'markdown', 'email', 'url'])
244
+
245
+ /** Characters, as the backend counts them — code points, not UTF-16 units. */
246
+ const charCount = (s) => [...s].length
247
+
248
+ /**
249
+ * A `date` at the precision its field declares, as the backend checks it (MEASURED 2026-10-14):
250
+ * `format: year-month` holds `YYYY-MM`, a month `01` to `12`; `format: year` holds `YYYY`; and
251
+ * with no `format`, a day the calendar has (`2024-5-3` passes, `2024-05` and `2024-02-30` do not).
252
+ */
253
+ function isDateAt(value, format) {
254
+ if (format === 'year-month') return /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
255
+ if (format === 'year') return /^\d{4}$/.test(value)
256
+ return isDate(value)
257
+ }
258
+
259
+ /** `YYYY-MM-DD`, a day the calendar has. */
260
+ function isDate(value) {
261
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value)
262
+ if (!m) return false
263
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]
264
+ const day = new Date(Date.UTC(y, mo - 1, d))
265
+ return day.getUTCFullYear() === y && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d
266
+ }
267
+
268
+ /**
269
+ * A `format` the backend checks, by its own rules — `email`: one `@`, something before it,
270
+ * a dotted domain after it, no space; `url`: a scheme, `://`, something after it, no space.
271
+ * ⚠️ `currency` is checked there too, against ISO 4217's codes, and not here. Every other
272
+ * `format` marks what the text is and checks nothing.
273
+ */
274
+ function matchesFormat(value, format) {
275
+ if (format === 'email') {
276
+ const parts = value.split('@')
277
+ return parts.length === 2 && parts[0] !== '' && parts[1].includes('.') && !parts[1].startsWith('.') && !parts[1].endsWith('.') && !/\s/.test(value)
278
+ }
279
+ if (format === 'url') {
280
+ const at = value.indexOf('://')
281
+ return at > 0 && /^[A-Za-z0-9+.-]+$/.test(value.slice(0, at)) && value.length > at + 3 && !/\s/.test(value)
282
+ }
283
+ return true
284
+ }
285
+
286
+ /**
287
+ * The checks on one text value — a field's own, or one language of a localized one —
288
+ * as the backend makes them: `max_length` in characters, then the `format`. `at` names
289
+ * the value: the field, or `field.lang`.
290
+ */
291
+ function checkText(field, value, at) {
292
+ if (typeof value !== 'string') return { field: at, rule: 'type', detail: `item.data.${at} must be a string` }
293
+ if (Number.isInteger(field.max_length) && charCount(value) > field.max_length) {
294
+ return {
295
+ field: at,
296
+ rule: 'max_length',
297
+ detail: `item.data.${at} exceeds max_length=${field.max_length}`,
298
+ params: { max_length: field.max_length },
299
+ }
300
+ }
301
+ if (typeof field.format === 'string' && value !== '' && !matchesFormat(value, field.format)) {
302
+ return {
303
+ field: at,
304
+ rule: 'format',
305
+ detail: `item.data.${at} = ${JSON.stringify(value)} is not a valid ${field.format}`,
306
+ params: { format: field.format },
307
+ }
308
+ }
309
+ return null
310
+ }
311
+
228
312
  /**
229
- * Check a record against a lowered `fields` map. Returns a list of violations.
313
+ * Check a record against a lowered `fields` map. Returns a list of violations, each
314
+ * `{ field, rule, detail, params? }` — `rule` named as the backend names it, `params` the
315
+ * declaration's bound it carries beside it (`max_length`, `min`, `max`, `format`).
230
316
  *
231
317
  * ⛔ **A key the schema does not declare is NOT a violation.** We have no evidence
232
318
  * that the real store rejects extras, and inventing that rule here would refuse
@@ -239,16 +325,32 @@ export function checkFields(fields, data, path = '') {
239
325
  const record = data && typeof data === 'object' ? data : {}
240
326
 
241
327
  for (const [name, field] of Object.entries(fields)) {
328
+ // A nested section is a section of its own, with items of its own — not a field.
329
+ if (field?.type === 'section') continue
242
330
  const at = path ? `${path}.${name}` : name
243
331
  const value = record[name]
244
332
  const missing = value === undefined || value === null
245
333
 
246
334
  if (field.required && missing) {
247
- problems.push({ field: at, rule: 'required', detail: `'${at}' is required` })
335
+ problems.push({ field: at, rule: 'required', detail: `item.data.${at} is required` })
248
336
  continue
249
337
  }
250
338
  if (missing) continue
251
339
 
340
+ // ⭐ A localized text field holds a string — its value in every language — or a
341
+ // per-language object, each language's value checked as the field's text is
342
+ // (MEASURED 2026-10-14). `@` is the object's metadata, not a language.
343
+ if (field.localized === true && TEXT_TYPES.has(field.type) && field.multiple !== true) {
344
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
345
+ const found = Object.entries(value)
346
+ .filter(([lang]) => lang !== '@')
347
+ .map(([lang, v]) => checkText(field, v, `${at}.${lang}`))
348
+ .find(Boolean)
349
+ if (found) problems.push(found)
350
+ continue
351
+ }
352
+ }
353
+
252
354
  // A list, in either spelling. The VALUE must be an array; each entry is then
253
355
  // checked against the element shape, indexed the way `uniweb validate` names it
254
356
  // (`outcomes[1]`), so one vocabulary covers both lanes.
@@ -260,14 +362,28 @@ export function checkFields(fields, data, path = '') {
260
362
  }
261
363
  value.forEach((entry, i) => {
262
364
  const entryAt = `${at}[${i}]`
365
+ // A localized list's entry is a string or a per-language object, as a single value is.
366
+ if (field.localized === true && TEXT_TYPES.has(element.type) && entry && typeof entry === 'object' && !Array.isArray(entry)) {
367
+ const found = Object.entries(entry)
368
+ .filter(([lang]) => lang !== '@')
369
+ .map(([lang, v]) => checkText(field, v, `${entryAt}.${lang}`))
370
+ .find(Boolean)
371
+ if (found) problems.push(found)
372
+ return
373
+ }
263
374
  if (satisfiesType(element.type, entry) === false) {
264
375
  problems.push({
265
376
  field: entryAt,
266
377
  rule: 'type',
267
- detail: `'${entryAt}' expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
378
+ detail: `item.data.${entryAt} expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
268
379
  })
269
380
  return
270
381
  }
382
+ const bounded = boundOf({ ...field, type: element.type }, entry, entryAt)
383
+ if (bounded) {
384
+ problems.push(bounded)
385
+ return
386
+ }
271
387
  if (element.fields) problems.push(...checkFields(element.fields, entry, entryAt))
272
388
  })
273
389
  continue
@@ -277,13 +393,20 @@ export function checkFields(fields, data, path = '') {
277
393
  problems.push({
278
394
  field: at,
279
395
  rule: 'type',
280
- detail: `'${at}' expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
396
+ detail: `item.data.${at} expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
281
397
  })
282
398
  continue
283
399
  }
284
400
 
401
+ const bounded = boundOf(field, value, at)
402
+ if (bounded) {
403
+ problems.push(bounded)
404
+ continue
405
+ }
406
+
407
+ // An `enum` is the backend's `one_of` rule — refused under that name.
285
408
  if (Array.isArray(field.enum) && field.enum.length && !field.enum.includes(value)) {
286
- problems.push({ field: at, rule: 'enum', detail: `'${at}' must be one of ${field.enum.join(', ')}` })
409
+ problems.push({ field: at, rule: 'one_of', detail: `item.${at} = ${JSON.stringify(value)} is not one of the allowed values` })
287
410
  continue
288
411
  }
289
412
 
@@ -292,6 +415,28 @@ export function checkFields(fields, data, path = '') {
292
415
  return problems
293
416
  }
294
417
 
418
+ /**
419
+ * The bound a value of a field's type breaks, as the backend checks it — `max_length`
420
+ * on text, `min` and `max` on a number, a `date`'s precision — or null.
421
+ * (MEASURED 2026-10-14, each with the bound beside the rule.)
422
+ */
423
+ function boundOf(field, value, at) {
424
+ if (TEXT_TYPES.has(field.type) && typeof value === 'string') return checkText(field, value, at)
425
+ if (typeof value === 'number' && ['int', 'integer', 'decimal', 'number'].includes(field.type)) {
426
+ if (typeof field.min === 'number' && value < field.min) {
427
+ return { field: at, rule: 'min', detail: `item.data.${at}=${value} below min=${field.min}`, params: { min: field.min } }
428
+ }
429
+ if (typeof field.max === 'number' && value > field.max) {
430
+ return { field: at, rule: 'max', detail: `item.data.${at}=${value} above max=${field.max}`, params: { max: field.max } }
431
+ }
432
+ }
433
+ if (field.type === 'date' && typeof value === 'string' && !isDateAt(value, field.format)) {
434
+ const shape = ['year-month', 'year'].includes(field.format) ? field.format : 'date'
435
+ return { field: at, rule: 'format', detail: `item.data.${at} is not a valid ${shape} (\`${value}\`)`, params: { format: shape } }
436
+ }
437
+ return null
438
+ }
439
+
295
440
  /** Keys present in the write that the section does not declare. Diagnostic only — see checkFields. */
296
441
  function undeclaredKeys(fields, data) {
297
442
  if (!fields || !data || typeof data !== 'object') return []