@nuxodin/item 0.6.4

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/item.d.ts +116 -0
  4. package/item.js +361 -0
  5. package/package.json +24 -0
  6. package/src/AsyncDataPoint.d.ts +52 -0
  7. package/src/AsyncDataPoint.js +280 -0
  8. package/src/Collection.experiment.js +63 -0
  9. package/src/Emitter.js +103 -0
  10. package/src/proxy.js +77 -0
  11. package/src/signal.js +95 -0
  12. package/tools/AsyncChild.js +44 -0
  13. package/tools/access.js +36 -0
  14. package/tools/beta/collectChanges.js +88 -0
  15. package/tools/beta/syncWith.js +22 -0
  16. package/tools/condition.js +65 -0
  17. package/tools/csvDataItem.js +139 -0
  18. package/tools/db/connect.js +27 -0
  19. package/tools/db/dialect/mysql.js +12 -0
  20. package/tools/db/dialect/pg.js +15 -0
  21. package/tools/db/dialect/sqlite.js +12 -0
  22. package/tools/db/shared.js +7 -0
  23. package/tools/db/sql.d.ts +24 -0
  24. package/tools/db/sql.js +83 -0
  25. package/tools/httpRouter.js +66 -0
  26. package/tools/jsonDataItem.js +29 -0
  27. package/tools/schema/build/input.js +120 -0
  28. package/tools/schema/db/indexeddb/from-db.js +29 -0
  29. package/tools/schema/db/indexeddb/to-db.js +115 -0
  30. package/tools/schema/db/mysql/from-db.js +16 -0
  31. package/tools/schema/db/mysql/from-field.js +101 -0
  32. package/tools/schema/db/mysql/to-db.js +275 -0
  33. package/tools/schema/db/mysql/to-field.js +60 -0
  34. package/tools/schema/db/pg/from-db.js +48 -0
  35. package/tools/schema/db/pg/from-field.js +71 -0
  36. package/tools/schema/db/pg/to-db.js +223 -0
  37. package/tools/schema/db/pg/to-field.js +52 -0
  38. package/tools/schema/db/shared/from-db.js +29 -0
  39. package/tools/schema/db/shared/sql.js +26 -0
  40. package/tools/schema/db/sqlite/from-db.js +39 -0
  41. package/tools/schema/db/sqlite/from-field.js +49 -0
  42. package/tools/schema/db/sqlite/to-db.js +127 -0
  43. package/tools/schema/db/sqlite/to-field.js +34 -0
  44. package/tools/schema/diff.js +90 -0
  45. package/tools/schema/dom/toInput.js +6 -0
  46. package/tools/schema/render/dom.js +26 -0
  47. package/tools/schema/render/html.js +40 -0
  48. package/tools/schema/utils.js +131 -0
  49. package/tools/schema/validator.js +92 -0
  50. package/tools/view/dom.js +96 -0
  51. package/tools/view/uhtml.js +40 -0
  52. package/tools/wsDenoRouter.js +108 -0
@@ -0,0 +1,127 @@
1
+ // sqlite/to-db.js
2
+ // Note: SQLite cannot modify or drop columns — changes require table recreation
3
+ import { schemaFromDb, singleColumnIndexes } from './from-db.js'
4
+ import { toFieldDef } from './to-field.js'
5
+ import { quoteId, schemaType } from '../shared/sql.js'
6
+ import { schemaDiff } from '../../diff.js'
7
+
8
+ function tableFields(tableSchema) {
9
+ return Object.entries(tableSchema.additionalProperties?.properties ?? {})
10
+ }
11
+
12
+ const indexName = (table, name) => `idx_${table}_${name}`
13
+
14
+ // SQLite: a single integer primary key must be declared inline as
15
+ // `INTEGER PRIMARY KEY [AUTOINCREMENT]`; AUTOINCREMENT is invalid elsewhere.
16
+ // Composite or non-integer keys use a table-level PRIMARY KEY constraint.
17
+ function createBody(fields, primaries) {
18
+ const pkProp = fields.find(([n]) => n === primaries[0])?.[1]
19
+ const soloInt = primaries.length === 1 && pkProp &&
20
+ ['integer', 'boolean'].includes(schemaType(pkProp))
21
+ const cols = fields.map(([n, f]) => {
22
+ const isPk = soloInt && n === primaries[0]
23
+ // Only `INTEGER PRIMARY KEY` is the rowid alias, and AUTOINCREMENT is legal nowhere else —
24
+ // so a boolean key keeps the integer spelling, which SQLite would otherwise reject.
25
+ let def = toFieldDef(n, isPk ? { ...f, type: 'integer' } : f)
26
+ if (isPk) def += f['x-autoincrement'] ? ' PRIMARY KEY AUTOINCREMENT' : ' PRIMARY KEY'
27
+ return ' ' + def
28
+ }).join(',\n')
29
+ const pk = (!soloInt && primaries.length) ? `,\n PRIMARY KEY (${primaries.map(quoteId).join(', ')})` : ''
30
+ return `${cols}${pk}`
31
+ }
32
+
33
+ function indexKind(table, name, prop) {
34
+ if (prop['x-index'] === 'unique') return 'UNIQUE INDEX'
35
+ if (prop['x-index'] === true) return 'INDEX'
36
+ if (prop['x-index'] === 'fulltext')
37
+ console.warn(`Skip FULLTEXT INDEX ${table}.${name}: SQLite fulltext requires a virtual FTS table`)
38
+ return null
39
+ }
40
+
41
+ function createIndex(table, name, kind) {
42
+ return `CREATE ${kind} ${quoteId(indexName(table, name))} ON ${quoteId(table)} (${quoteId(name)});`
43
+ }
44
+
45
+ function indexStatements(table, fields, primaries, current = [], { patch = false } = {}) {
46
+ const stmts = [], wanted = new Set()
47
+ for (const [name, prop] of fields) {
48
+ if (primaries.includes(name)) continue
49
+ const kind = indexKind(table, name, prop)
50
+ if (!kind) continue
51
+ const idx = indexName(table, name), unique = kind === 'UNIQUE INDEX'
52
+ const curr = current.find(i => i.name === idx) ?? current.find(i => i.column === name && i.unique === unique)
53
+ wanted.add(idx)
54
+ if (curr && curr.column === name && curr.unique === unique) continue
55
+ if (curr?.name === idx) stmts.push(`DROP INDEX ${quoteId(curr.name)};`)
56
+ stmts.push(createIndex(table, name, kind))
57
+ }
58
+ if (!patch) {
59
+ for (const curr of current) {
60
+ if (curr.name.startsWith(`idx_${table}_`) && !wanted.has(curr.name))
61
+ stmts.push(`DROP INDEX ${quoteId(curr.name)};`)
62
+ }
63
+ }
64
+ return stmts
65
+ }
66
+
67
+ export async function schemaToDb(schema, query, { force = false, patch = false } = {}) {
68
+ const current = await schemaFromDb(query)
69
+ const diffs = schemaDiff(schema, current)
70
+
71
+ if (!patch) {
72
+ const destructive = diffs.filter(d => d.destructive === true)
73
+ if (destructive.length && !force) {
74
+ const msgs = destructive.map(d => ` ${d.path.join('.')} — ${d.msg}`).join('\n')
75
+ throw new Error(`Destructive schema changes detected (use force to apply):\n${msgs}`)
76
+ }
77
+ }
78
+
79
+ const stmts = []
80
+ const nextTables = Object.keys(schema.properties ?? {})
81
+ const currTables = Object.keys(current.properties ?? {})
82
+
83
+ for (const table of nextTables) {
84
+ const fields = tableFields(schema.properties[table])
85
+ const primaries = fields.filter(([, f]) => f['x-index'] === 'primary').map(([n]) => n)
86
+
87
+ if (!currTables.includes(table)) {
88
+ stmts.push(`CREATE TABLE ${quoteId(table)} (\n${createBody(fields, primaries)}\n);`)
89
+ stmts.push(...indexStatements(table, fields, primaries))
90
+ } else {
91
+ const currFields = Object.keys(current.properties[table]?.additionalProperties?.properties ?? {})
92
+ const hasChanges = diffs.some(d => d.path[0] === 'properties' && d.path[1] === table)
93
+ const hasDrops = !patch && currFields.some(n => !fields.find(([fn]) => fn === n))
94
+
95
+ if (hasChanges && (hasDrops || !patch)) {
96
+ const tmp = `${table}_migration_tmp`
97
+ const keep = fields.map(([n]) => n).filter(n => currFields.includes(n))
98
+ const colsList = keep.map(quoteId).join(', ')
99
+
100
+ stmts.push(`CREATE TABLE ${quoteId(tmp)} (\n${createBody(fields, primaries)}\n);`)
101
+ if (keep.length) stmts.push(`INSERT INTO ${quoteId(tmp)} (${colsList}) SELECT ${colsList} FROM ${quoteId(table)};`)
102
+ stmts.push(`DROP TABLE ${quoteId(table)};`)
103
+ stmts.push(`ALTER TABLE ${quoteId(tmp)} RENAME TO ${quoteId(table)};`)
104
+ stmts.push(...indexStatements(table, fields, primaries))
105
+ } else {
106
+ for (const [name, prop] of fields) {
107
+ if (!currFields.includes(name))
108
+ stmts.push(`ALTER TABLE ${quoteId(table)} ADD COLUMN ${toFieldDef(name, prop)};`)
109
+ }
110
+ stmts.push(...indexStatements(table, fields, primaries, await singleColumnIndexes(query, table), { patch }))
111
+ }
112
+ }
113
+ }
114
+
115
+ if (!patch) {
116
+ for (const table of currTables) {
117
+ if (!nextTables.includes(table))
118
+ stmts.push(`DROP TABLE ${quoteId(table)};`)
119
+ }
120
+ }
121
+
122
+ for (const stmt of stmts) {
123
+ console.log(stmt)
124
+ await query(stmt)
125
+ }
126
+ return { diffs, executed: stmts }
127
+ }
@@ -0,0 +1,34 @@
1
+ // sqlite/to-field.js — JSON Schema property → SQLite column definition
2
+ // SQLite lengths are in characters — no byte conversion needed
3
+ // Intentionally no NOT NULL for required fields: SQLite has no non-strict mode that
4
+ // fills implicit defaults like MySQL, so inserts omitting required fields would fail.
5
+ import { quoteId, schemaType, defaultLiteral } from '../shared/sql.js'
6
+
7
+ export function toFieldDef(name, prop) {
8
+ let type
9
+ const t = schemaType(prop)
10
+
11
+ // SQLite has no bool, but the declared name still matters: BOOLEAN carries NUMERIC affinity,
12
+ // so 1/0 stay integers, and schemaFromField reads it back as boolean. Spelling it INTEGER
13
+ // round-trips to integer instead, leaving a type diff that never settles.
14
+ // (A STRICT table would reject the name — that would need INTEGER and a different carrier.)
15
+ if (t === 'boolean') type = 'BOOLEAN'
16
+ else if (t === 'integer') type = 'INTEGER'
17
+ else if (t === 'number') type = 'REAL'
18
+ else if (t === 'object' || t === 'array') type = 'TEXT' // JSON as text
19
+ else {
20
+ if (prop.format === 'date') type = 'TEXT'
21
+ else if (prop.format === 'time') type = 'TEXT'
22
+ else if (prop.format === 'date-time') type = 'TEXT'
23
+ else if (prop.contentEncoding === 'base64') type = 'BLOB'
24
+ else if (prop.maxLength) type = `VARCHAR(${prop.maxLength})`
25
+ else type = 'TEXT'
26
+ }
27
+
28
+ // PRIMARY KEY / AUTOINCREMENT are emitted inline by to-db.js for the single
29
+ // integer-primary case; SQLite rejects AUTOINCREMENT in any other position.
30
+ let sql = `${quoteId(name)} ${type}`
31
+ if (prop.default != null) sql += ` DEFAULT ${defaultLiteral(prop)}`
32
+
33
+ return sql
34
+ }
@@ -0,0 +1,90 @@
1
+ // ── Generic JSON diff ─────────────────────────────────────────────────────────
2
+
3
+ export function jsonDiff(next, orig, classify, path = []) {
4
+ const diffs = []
5
+ const keys = new Set([...Object.keys(orig ?? {}), ...Object.keys(next ?? {})])
6
+ for (const key of keys) {
7
+ const a = orig?.[key]
8
+ const b = next?.[key]
9
+ const p = [...path, key]
10
+ if (a === b) continue
11
+ if (isObject(a) && isObject(b)) {
12
+ diffs.push(...jsonDiff(b, a, classify, p))
13
+ continue
14
+ }
15
+ const destructive = classify(key, a, b)
16
+ diffs.push({
17
+ path: p,
18
+ property: key,
19
+ msg: destructive === null ? null : describeChange(key, a, b),
20
+ prev: a,
21
+ next: b,
22
+ destructive,
23
+ })
24
+ }
25
+ return diffs
26
+ }
27
+
28
+ function isObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v) }
29
+ function describeChange(key, a, b) {
30
+ if (a === undefined) return `${key} added`
31
+ if (b === undefined) return `${key} removed`
32
+ return `${key} changed`
33
+ }
34
+
35
+ // ── Schema-specific rules ─────────────────────────────────────────────────────
36
+
37
+ const schemaRules = {
38
+ type: (a, b) => a !== undefined && b !== undefined, // type change = always destructive
39
+ maxLength: (a, b) => b < a,
40
+ minLength: (a, b) => b > a,
41
+ minimum: (a, b) => b > a,
42
+ maximum: (a, b) => b < a,
43
+ minItems: (a, b) => b > a,
44
+ maxItems: (a, b) => b < a,
45
+ minProperties: (a, b) => b > a,
46
+ maxProperties: (a, b) => b < a,
47
+ required: (a, b) => { // new required fields = destructive
48
+ if (!Array.isArray(a) || !Array.isArray(b)) return true
49
+ return b.some(f => !a.includes(f))
50
+ },
51
+ enum: (a, b) => { // removing enum values = destructive
52
+ if (!Array.isArray(a) || !Array.isArray(b)) return true
53
+ return a.some(v => !b.includes(v))
54
+ },
55
+ // tightening = destructive, loosening = safe
56
+ additionalProperties: (a, b) => b === false && a !== false,
57
+ uniqueItems: (a, b) => b === true && a !== true,
58
+ pattern: (a, b) => a !== b, // any regex change may reject existing data
59
+ format: (a, b) => a !== b, // format change may reject existing data
60
+ const: (a, b) => a !== b,
61
+ contains: (a, b) => a !== b,
62
+ // always safe
63
+ 'x-index': (a, b) => a === 'primary' && b !== 'primary', // removing primary = destructive
64
+ properties: () => false,
65
+ patternProperties: () => false,
66
+ description: () => false,
67
+ title: () => false,
68
+ default: () => false,
69
+ examples: () => false,
70
+ '$comment': () => false,
71
+ '$schema': () => false,
72
+ '$id': () => false,
73
+ readOnly: () => false,
74
+ writeOnly: () => false,
75
+ deprecated: () => false,
76
+ }
77
+
78
+ export function schemaDiff(next, orig) {
79
+ return jsonDiff(next, orig, (prop, a, b) => {
80
+ const rule = schemaRules[prop]
81
+ if (!rule) {
82
+ if (b === undefined) return isObject(a) ? true : false // whole field removed = destructive
83
+ if (a === undefined) return false // whole field added = safe
84
+ return null // unknown change → flag
85
+ }
86
+ if (b === undefined) return false // removal of a constraint = safe
87
+ if (a === undefined) return false // addition = safe
88
+ return rule(a, b)
89
+ })
90
+ }
@@ -0,0 +1,6 @@
1
+ // @deprecated — moved to ../render/dom.js
2
+ // import { toInput } from '.../tools/schema/render/dom.js' // → HTMLElement
3
+ // import { toInput } from '.../tools/schema/render/html.js' // → string
4
+ console.warn('[schema/dom/toInput.js] deprecated — import { toInput } from "../render/dom.js" instead')
5
+
6
+ export { toInput } from '../render/dom.js'
@@ -0,0 +1,26 @@
1
+ import { inputNode } from '../build/input.js'
2
+
3
+ export function render(node) {
4
+ if (typeof node === 'string') return document.createTextNode(node)
5
+
6
+ const el = document.createElement(node.tag)
7
+
8
+ if (node.attrs) {
9
+ for (const [k, v] of Object.entries(node.attrs)) {
10
+ if (v === false || v == null) continue
11
+ if (k === 'indeterminate') { el.indeterminate = !!v; continue }
12
+ if (v === true) el.setAttribute(k, '')
13
+ else el.setAttribute(k, String(v))
14
+ }
15
+ }
16
+
17
+ if (node.children) {
18
+ for (const c of node.children) el.append(render(c))
19
+ }
20
+
21
+ return el
22
+ }
23
+
24
+ export function toInput(schema, opts) {
25
+ return render(inputNode(schema, opts))
26
+ }
@@ -0,0 +1,40 @@
1
+ import { inputNode } from '../build/input.js'
2
+
3
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
4
+
5
+ export function render(node) {
6
+ if (typeof node === 'string') return escapeText(node)
7
+
8
+ const attrs = renderAttrs(node.attrs)
9
+ if (VOID_TAGS.has(node.tag)) return `<${node.tag}${attrs}>`
10
+
11
+ const inner = (node.children ?? []).map(render).join('')
12
+ return `<${node.tag}${attrs}>${inner}</${node.tag}>`
13
+ }
14
+
15
+ export function toInput(schema, opts) {
16
+ return render(inputNode(schema, opts))
17
+ }
18
+
19
+ function renderAttrs(attrs) {
20
+ if (!attrs) return ''
21
+ const parts = []
22
+ for (const [k, v] of Object.entries(attrs)) {
23
+ if (v === false || v == null) continue
24
+ if (k === 'indeterminate') {
25
+ if (v) parts.push('data-indeterminate="true"')
26
+ continue
27
+ }
28
+ if (v === true) parts.push(k)
29
+ else parts.push(`${k}="${escapeAttr(v)}"`)
30
+ }
31
+ return parts.length ? ' ' + parts.join(' ') : ''
32
+ }
33
+
34
+ function escapeAttr(v) {
35
+ return String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
36
+ }
37
+
38
+ function escapeText(v) {
39
+ return v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
40
+ }
@@ -0,0 +1,131 @@
1
+ import { validate as validateSchema } from './validator.js';
2
+
3
+ // x-dataref convention:
4
+ // '../../groups' → the groups table
5
+ // '../../groups/$' → the specific entry: groups.item(this.value)
6
+ //
7
+ // path parts:
8
+ // .. = parent
9
+ // . = self
10
+ // # = schema root
11
+ // $ = item(currentValue) ← resolves the FK to a concrete item
12
+ // name = named child
13
+
14
+ export function getSchemaRoot(item) {
15
+ let p = item
16
+ while (p.parent && p.parent.schema) p = p.parent
17
+ return p
18
+ }
19
+
20
+
21
+ export function resolveRef(ref, item, skipLevels = 0) {
22
+ const parts = ref.split('/').filter(p => p !== '');
23
+ let anchor = item;
24
+ if (parts[0] === '#') {
25
+ anchor = getSchemaRoot(item);
26
+ parts.shift();
27
+ skipLevels = 0;
28
+ }
29
+ let skip = skipLevels;
30
+ while (skip > 0) {
31
+ if (parts[0] === '..') { parts.shift(); skip--; }
32
+ else return undefined;
33
+ }
34
+ for (let i = 0; i < parts.length; i++) {
35
+ const part = parts[i];
36
+ if (part === '..') anchor = anchor?.parent;
37
+ else if (part === '.') {}
38
+ else return anchor?.has(parts.slice(i));
39
+ if (!anchor) return undefined;
40
+ }
41
+ return anchor;
42
+ }
43
+
44
+ function zzzzresolveRef(ref, item) {
45
+ const parts = ref.split('/');
46
+ let anchor = item;
47
+ for (const part of parts) {
48
+ //if (part === '#') { while (anchor.parent) anchor = anchor.parent } // data-root
49
+ if (part === '#') anchor = getSchemaRoot(item); // schema-root, besser!?
50
+ else if (part === '..') anchor = anchor.parent;
51
+ else if (part === '.') {}
52
+ else anchor = anchor.item(part);
53
+ }
54
+ return anchor;
55
+ }
56
+
57
+ // export function resolveDataRef(item) {
58
+ // const xref = item.schema?.['x-dataref'];
59
+ // if (!xref) return undefined;
60
+ // return resolveRef(xref, item);
61
+ // }
62
+
63
+
64
+ export function resolveDataRef(item) {
65
+ const xref = item.schema?.['x-dataref'];
66
+ if (!xref) return undefined;
67
+ const hasValueRef = xref.endsWith('/$');
68
+ const parentRef = hasValueRef ? xref.slice(0, -2) : xref;
69
+ const parent = resolveRef(parentRef, item);
70
+ const refItem = parent?.has(item);
71
+ return { parent, refItem };
72
+ }
73
+
74
+ export function reportDeprecated(item) {
75
+ item.addEventListener('getIn', e => {
76
+ if (e.target.schema?.deprecated) {
77
+ console.error(`item deprecated: ${e.target.path.join('/')}`, e.target.schema);
78
+ }
79
+ });
80
+ }
81
+
82
+ export function applyDefault(e) {
83
+ if (e.value == null) {
84
+ const d = e.target.schema?.default;
85
+ if (d !== undefined) e.value = d;
86
+ }
87
+ }
88
+
89
+ export function validate(e, options = { log: true }) {
90
+ const schema = e.target.schema;
91
+ if (!schema) return;
92
+ const value = e.value;
93
+ if (!validateSchema(schema, value).length) return;
94
+ e.preventDefault();
95
+ if (options.throw) throw new Error(`item invalid: ${e.target.path.join('/')}`);
96
+ if (options.log) console.error(`item invalid: ${e.target.path.join('/')}`, e.target.schema);
97
+ }
98
+
99
+ export function transform(event) {
100
+
101
+ if (event.options?.local) return; // comes from the server, we trust it
102
+ if (!event.target.schema) { return; }
103
+ if (event.target.schema.properties) { return; }
104
+ if (event.target.schema.additionalProperties) { return; }
105
+ if (event.value !== null && typeof event.value === 'object') { return; }
106
+
107
+ // first the native types
108
+ if (event.target.schema.type === 'integer') event.value = parseInt(event.value);
109
+ if (event.target.schema.type === 'number') event.value = parseFloat(event.value);
110
+ if (event.target.schema.type === 'boolean') event.value = !!event.value;
111
+
112
+
113
+ let trim = true; // default
114
+ if ('x-transform' in event.target.schema) {
115
+ trim = event.target.schema['x-transform'].trim ?? true;
116
+ }
117
+ if (event.target.schema.type === 'string') {
118
+
119
+ if (trim==='L') event.value = event.value?.trimStart?.();
120
+ if (trim==='R') event.value = event.value?.trimEnd?.();
121
+ if (trim===true) event.value = event.value?.trim?.();
122
+
123
+ if (event.value != null) {
124
+ const c = event.target.schema?.['x-transform']?.case;
125
+ if (c === 'lower') event.value = event.value.toLowerCase();
126
+ if (c === 'upper') event.value = event.value.toUpperCase();
127
+ }
128
+
129
+ }
130
+
131
+ }
@@ -0,0 +1,92 @@
1
+
2
+ /**
3
+ * Lightweight JSON Schema Validator
4
+ *
5
+ * @param {Object} schema - JSON Schema
6
+ * @param {any} data - Data to validate
7
+ * @param {string} path - JSON Pointer to current location in data
8
+ * @returns {Array} - Array of {path,msg} error objects
9
+ */
10
+
11
+ export function validate(schema, data, path = '#') {
12
+ const errs = [];
13
+ const err = msg => errs.push({ path, msg });
14
+ const sub = (s, v, p) => errs.push(...validate(s, v, p));
15
+
16
+ for (const [kw, fn] of Object.entries(rules)) {
17
+ if (!(kw in schema)) continue;
18
+ const r = fn(schema, data, path, sub);
19
+ if (typeof r === 'string') err(r);
20
+ else if (r?.length) errs.push(...r);
21
+ }
22
+ return errs;
23
+ }
24
+
25
+ const rules = {
26
+ // universal
27
+ const: (s, d) => !deepEq(d, s.const) && `must be ${JSON.stringify(s.const)}`,
28
+ enum: (s, d) => !s.enum.some(v => deepEq(v, d)) && `must be one of ${JSON.stringify(s.enum)}`,
29
+ type: (s, d) => { const ts = [].concat(s.type); return !ts.some(t => checkType(t, d)) && `must be type ${ts.join('|')} (got ${jsType(d)})`; },
30
+
31
+ // string
32
+ minLength: (s, d) => typeof d === 'string' && d.length < s.minLength && `minLength ${s.minLength} (got ${d.length})`,
33
+ maxLength: (s, d) => typeof d === 'string' && d.length > s.maxLength && `maxLength ${s.maxLength} (got ${d.length})`,
34
+ pattern: (s, d) => typeof d === 'string' && !new RegExp(s.pattern).test(d) && `pattern /${s.pattern}/ not matched`,
35
+
36
+ // number
37
+ minimum: (s, d) => typeof d === 'number' && d < s.minimum && `minimum ${s.minimum} (got ${d})`,
38
+ maximum: (s, d) => typeof d === 'number' && d > s.maximum && `maximum ${s.maximum} (got ${d})`,
39
+ exclusiveMinimum: (s, d) => typeof d === 'number' && d <= s.exclusiveMinimum && `exclusiveMinimum ${s.exclusiveMinimum}`,
40
+ exclusiveMaximum: (s, d) => typeof d === 'number' && d >= s.exclusiveMaximum && `exclusiveMaximum ${s.exclusiveMaximum}`,
41
+ multipleOf: (s, d) => typeof d === 'number' && d % s.multipleOf !== 0 && `must be multiple of ${s.multipleOf}`,
42
+
43
+ // array
44
+ minItems: (s, d) => Array.isArray(d) && d.length < s.minItems && `minItems ${s.minItems} (got ${d.length})`,
45
+ maxItems: (s, d) => Array.isArray(d) && d.length > s.maxItems && `maxItems ${s.maxItems} (got ${d.length})`,
46
+ uniqueItems: (s, d) => Array.isArray(d) && s.uniqueItems && d.length !== new Set(d.map(JSON.stringify)).size && 'items must be unique',
47
+ items: (s, d, path, sub) => Array.isArray(d) && d.forEach((item, i) => {
48
+ const sc = Array.isArray(s.items) ? s.items[i] : s.items;
49
+ if (sc) sub(sc, item, `${path}[${i}]`);
50
+ }),
51
+
52
+ // object — returns array of {path,msg} for multi-path errors
53
+ required: (s, d, path) => isObj(d) &&
54
+ s.required.filter(k => !(k in d)).map(k => ({ path: `${path}.${k}`, msg: 'is required' })),
55
+ properties: (s, d, path, sub) => isObj(d) &&
56
+ Object.entries(s.properties).forEach(([k, sc]) => k in d && sub(sc, d[k], `${path}.${k}`)),
57
+ additionalProperties: (s, d, path, sub) => {
58
+ if (!isObj(d)) return;
59
+ const props = s.properties || {};
60
+ const extra = Object.entries(d).filter(([k]) => !(k in props));
61
+ if (s.additionalProperties === false)
62
+ return extra.map(([k]) => ({ path: `${path}.${k}`, msg: 'additional property not allowed' }));
63
+ if (typeof s.additionalProperties === 'object')
64
+ extra.forEach(([k, v]) => sub(s.additionalProperties, v, `${path}.${k}`));
65
+ },
66
+
67
+ // composition
68
+ allOf: (s, d, path, sub) => s.allOf.forEach(sc => sub(sc, d, path)),
69
+ anyOf: (s, d, path) => !s.anyOf.some(sc => validate(sc, d, path).length === 0) && 'must match at least one of anyOf',
70
+ oneOf: (s, d, path) => { const n = s.oneOf.filter(sc => validate(sc, d, path).length === 0).length; return n !== 1 && `must match exactly one of oneOf (matched ${n})`; },
71
+ not: (s, d, path) => validate(s.not, d, path).length === 0 && 'must not match "not" schema',
72
+ if: (s, d, path, sub) => { const branch = validate(s.if, d, path).length === 0 ? s.then : s.else; if (branch) sub(branch, d, path); },
73
+ };
74
+
75
+ const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
76
+ const deepEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
77
+
78
+ function checkType(t, v) {
79
+ if (t === 'integer') return Number.isInteger(v);
80
+ if (t === 'number') return typeof v === 'number' && !isNaN(v);
81
+ if (t === 'null') return v === null;
82
+ if (t === 'array') return Array.isArray(v);
83
+ if (t === 'object') return isObj(v);
84
+ return typeof v === t;
85
+ }
86
+
87
+ function jsType(v) {
88
+ if (v === null) return 'null';
89
+ if (Array.isArray(v)) return 'array';
90
+ if (Number.isInteger(v)) return 'integer';
91
+ return typeof v;
92
+ }
@@ -0,0 +1,96 @@
1
+ import { resolveRef } from '../schema/utils.js';
2
+ import { toInput } from '../schema/render/dom.js';
3
+
4
+ const isPrimary = (s) => s['x-index'] === 'primary';
5
+
6
+ export function field(item, editable = true) {
7
+ const s = item.schema ?? {};
8
+ if (!editable || s.readOnly || isPrimary(s)) {
9
+ if (s['x-dataref']) return refDisplay(item);
10
+ const span = document.createElement('span');
11
+ span.textContent = item.get();
12
+ item.addEventListener('change', () => span.textContent = item.get());
13
+ return span;
14
+ }
15
+ const el = s['x-dataref'] ? refSelect(item) : toInput(s);
16
+ return bindInput(el, item);
17
+ }
18
+
19
+ export function selectFromChildren(parent) {
20
+ const select = document.createElement('select');
21
+ const update = async () => {
22
+ if (!parent.filled) await parent.read();
23
+ const prev = select.value;
24
+ select.innerHTML = '';
25
+ select.append(new Option('—', ''));
26
+ for (const child of parent) {
27
+ const display = child.schema?.['x-display'] ?? 'name';
28
+ if (!child.filled) await child.read();
29
+ if (child.has(display)?.filled) await child.has(display).read();
30
+ const opt = new Option(String(child.has(display) ?? child.key), child.key);
31
+ select.append(opt);
32
+ }
33
+ if (prev && !select.querySelector(`option[value="${prev}"]`)) {
34
+ select.append(new Option(prev, prev));
35
+ }
36
+ select.value = prev;
37
+ };
38
+ select.addEventListener('focus', update);
39
+ update();
40
+ return select;
41
+ }
42
+
43
+ export function bindInput(el, item) {
44
+ const sync = () => {
45
+ if (el.type === 'checkbox') {
46
+ el.checked = !!item.get();
47
+ } else {
48
+ const val = String(item.get() ?? '');
49
+ if (el.tagName === 'SELECT' && !el.querySelector(`option[value=${JSON.stringify(val)}]`)) {
50
+ el.append(Object.assign(document.createElement('option'), { value: val, textContent: val }));
51
+ }
52
+ el.value = val;
53
+ }
54
+ };
55
+ sync();
56
+ item.addEventListener('change', sync);
57
+ el.addEventListener('change', async () => {
58
+ const value = el.type === 'checkbox' ? el.checked : el.value;
59
+ el.setAttribute('state', 'saving');
60
+ try {
61
+ await item.set(value);
62
+ el.setAttribute('state', 'saved');
63
+ } catch (err) {
64
+ console.error(err);
65
+ el.setAttribute('state', 'error');
66
+ }
67
+ });
68
+ return el;
69
+ }
70
+
71
+ function refSelect(item) {
72
+ const xref = item.schema?.['x-dataref'];
73
+ const parentRef = xref.endsWith('/$') ? xref.slice(0, -2) : xref;
74
+ const parent = resolveRef(parentRef, item);
75
+ if (!parent) return document.createElement('select');
76
+ const select = selectFromChildren(parent);
77
+ return select;
78
+ }
79
+
80
+ function refDisplay(item) {
81
+ const xref = item.schema?.['x-dataref'];
82
+ const parentRef = xref.endsWith('/$') ? xref.slice(0, -2) : xref;
83
+ const parent = resolveRef(parentRef, item);
84
+ const span = document.createElement('span');
85
+ span.textContent = item.get() ?? '';
86
+ if (!parent) return span;
87
+ (async () => {
88
+ if (!parent.filled) await parent.read();
89
+ const refItem = parent.has(item.get() ?? '');
90
+ if (!refItem) return;
91
+ if (!refItem.filled) await refItem.read();
92
+ const display = refItem.schema?.['x-display'] ?? 'name';
93
+ span.textContent = String(refItem.has(display) ?? item.get());
94
+ })();
95
+ return span;
96
+ }