@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.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/item.d.ts +116 -0
- package/item.js +361 -0
- package/package.json +24 -0
- package/src/AsyncDataPoint.d.ts +52 -0
- package/src/AsyncDataPoint.js +280 -0
- package/src/Collection.experiment.js +63 -0
- package/src/Emitter.js +103 -0
- package/src/proxy.js +77 -0
- package/src/signal.js +95 -0
- package/tools/AsyncChild.js +44 -0
- package/tools/access.js +36 -0
- package/tools/beta/collectChanges.js +88 -0
- package/tools/beta/syncWith.js +22 -0
- package/tools/condition.js +65 -0
- package/tools/csvDataItem.js +139 -0
- package/tools/db/connect.js +27 -0
- package/tools/db/dialect/mysql.js +12 -0
- package/tools/db/dialect/pg.js +15 -0
- package/tools/db/dialect/sqlite.js +12 -0
- package/tools/db/shared.js +7 -0
- package/tools/db/sql.d.ts +24 -0
- package/tools/db/sql.js +83 -0
- package/tools/httpRouter.js +66 -0
- package/tools/jsonDataItem.js +29 -0
- package/tools/schema/build/input.js +120 -0
- package/tools/schema/db/indexeddb/from-db.js +29 -0
- package/tools/schema/db/indexeddb/to-db.js +115 -0
- package/tools/schema/db/mysql/from-db.js +16 -0
- package/tools/schema/db/mysql/from-field.js +101 -0
- package/tools/schema/db/mysql/to-db.js +275 -0
- package/tools/schema/db/mysql/to-field.js +60 -0
- package/tools/schema/db/pg/from-db.js +48 -0
- package/tools/schema/db/pg/from-field.js +71 -0
- package/tools/schema/db/pg/to-db.js +223 -0
- package/tools/schema/db/pg/to-field.js +52 -0
- package/tools/schema/db/shared/from-db.js +29 -0
- package/tools/schema/db/shared/sql.js +26 -0
- package/tools/schema/db/sqlite/from-db.js +39 -0
- package/tools/schema/db/sqlite/from-field.js +49 -0
- package/tools/schema/db/sqlite/to-db.js +127 -0
- package/tools/schema/db/sqlite/to-field.js +34 -0
- package/tools/schema/diff.js +90 -0
- package/tools/schema/dom/toInput.js +6 -0
- package/tools/schema/render/dom.js +26 -0
- package/tools/schema/render/html.js +40 -0
- package/tools/schema/utils.js +131 -0
- package/tools/schema/validator.js +92 -0
- package/tools/view/dom.js +96 -0
- package/tools/view/uhtml.js +40 -0
- 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, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeText(v) {
|
|
39
|
+
return v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|