@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,44 @@
1
+ import { Item } from "../item.js";
2
+
3
+ /*
4
+ AsyncChild is useful when you have a data source that only supports object replace
5
+ but not partial updates.
6
+ This item will then read the full parent object, update the child value and write the full object back.
7
+
8
+ Note: io regions do the write-through by themselves now (`owner.readsFull = true` + plain
9
+ Items as children). What remains here is the auto-read — the core throws on an unloaded
10
+ region instead. See doc/PLAN-io-regions.md: this class may be dropped entirely.
11
+ */
12
+
13
+ export class AsyncChild extends Item {
14
+ // reader delivers (via ioRoot) this child's whole subtree — licences partial writes once loaded
15
+ readsFull = true;
16
+
17
+ constructor(parent, key) {
18
+ super(parent, key);
19
+ this.ioRoot = parent instanceof AsyncChild ? parent.ioRoot : parent;
20
+ }
21
+
22
+ async reader() {
23
+ await this.ioRoot.read();
24
+ }
25
+
26
+ async writer(value) { // the source only supports full replace, so write the whole root region
27
+ // `loaded`, not `filled`: navigating to a child already fills the tree, only io knows
28
+ // whether the root's data ever actually arrived — writing an unread region would erase it.
29
+ if (this.ioRoot.reader && !this.ioRoot.loaded) {
30
+ await this.ioRoot.read();
31
+ this.set(value, { local: true }); // the read overwrote the fresh local change — re-apply
32
+ }
33
+ return this.ioRoot.io.set(this.ioRoot.peek());
34
+ }
35
+
36
+ async remover() {
37
+ await this.parent.read();
38
+ const data = this.parent.peek();
39
+ if (data) {
40
+ delete data[this.key];
41
+ await this.parent.set(structuredClone(data));
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,36 @@
1
+ /*
2
+ Usage:
3
+ import { canRead, canWrite } from './access.js';
4
+ import { resolveVariables } from './condition.js';
5
+ const resolvedSchema = resolveVariables(rawSchema, { $user: currentUser });
6
+ // schema auf root-item setzen...
7
+ await canRead(ticketItem); // true/false
8
+ await canWrite(ticketItem); // true/false
9
+ */
10
+ import { evaluate, resolveVariables } from './condition.js';
11
+ import { resolveDataRef } from './schema/utils.js';
12
+
13
+ export { resolveVariables }; // re-export für Convenience
14
+
15
+ async function onUnknownOperator(condition, obj, item) {
16
+ if (condition[0] === 'cascade') {
17
+ const fieldItem = item.item(condition[1]);
18
+ const { refItem } = await resolveDataRef(fieldItem) ?? {};
19
+ return refItem ? canRead(refItem) : false;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ async function check(condition, item) {
25
+ return evaluate(condition, item.get(), (cond, obj) => onUnknownOperator(cond, obj, item));
26
+ }
27
+
28
+ export async function canRead(item) {
29
+ const schema = item.schema?.additionalProperties ?? item.schema;
30
+ return await check(schema?.['x-readable'], item) || await check(schema?.['x-writable'], item);
31
+ }
32
+
33
+ export async function canWrite(item) {
34
+ const schema = item.schema?.additionalProperties ?? item.schema;
35
+ return check(schema?.['x-writable'], item);
36
+ }
@@ -0,0 +1,88 @@
1
+
2
+ const removedSymbol = Symbol('removed');
3
+
4
+ // An other variant would be to use structuralClone to clone the object and then compare it with the original
5
+ // This would probably use more memory on big objects but would be simpler, worth it?
6
+
7
+ export function collectChanges(rootItem, onchange) {
8
+ // items can be removed and then added again
9
+ // so on remove we add a symbol to mark it as removed than can be overwritten by a new value
10
+
11
+ // we need a wrapper to be able to have a reference even if the root value is not an object
12
+ let wrapper = {};
13
+
14
+ const startPathLevel = rootItem.path.length;
15
+
16
+ rootItem.addEventListener('changeIn', event => {
17
+ const {target, value, remove, add} = event;
18
+ if (add) return; // will be handled if the value is set (an other changeIn event)
19
+
20
+ const relativePath = target.path.slice(startPathLevel);
21
+ const path = ['changes', ...relativePath];
22
+ const last = path.pop();
23
+
24
+ let current = wrapper;
25
+ for (const key of path) {
26
+ if (!isObject(current[key])) current[key] = {};
27
+ current = current[key];
28
+ }
29
+ if (remove) {
30
+ current[last] ??= {};
31
+ current[last][remove.key] = removedSymbol;
32
+ } else {
33
+ current[last] = value ?? null;
34
+ }
35
+ onchange && onchange();
36
+ });
37
+ return {
38
+ getAndReset() {
39
+ const changes = wrapper.changes;
40
+ wrapper = {};
41
+
42
+ const deletions = {};
43
+ splitDeletions(changes, deletions);
44
+
45
+ return {changes, deletions};
46
+ },
47
+ }
48
+ }
49
+
50
+ function splitDeletions(obj, deletions){
51
+ for (const key in obj) {
52
+ if (obj[key] === removedSymbol) {
53
+ deletions[key] = true;
54
+ delete obj[key];
55
+ } else if (isObject(obj[key])) {
56
+ deletions[key] = {};
57
+ splitDeletions(obj[key], deletions[key]);
58
+ if (Object.keys(obj[key]).length === 0) delete obj[key];
59
+ if (Object.keys(deletions[key]).length === 0) delete deletions[key];
60
+ }
61
+ }
62
+ }
63
+
64
+
65
+ // patch
66
+ export function patch(item, {changes, deletions}) {
67
+ patchChanges(item, changes);
68
+ patchDeletions(item, deletions);
69
+ }
70
+ function patchChanges(item, changes) {
71
+ if (changes === undefined) return;
72
+ if (!isObject(changes)) {
73
+ item.value = changes;
74
+ } else {
75
+ for (const key in changes) {
76
+ patchChanges(item.item(key), changes[key]);
77
+ }
78
+ }
79
+ }
80
+ function patchDeletions(item, deletions) {
81
+ for (const key in deletions) {
82
+ if (deletions[key] === true) item.item(key).remove();
83
+ else if (isObject(deletions[key])) patchDeletions(item.item(key), deletions[key]);
84
+ else console.error('true or {}', deletions[key])
85
+ }
86
+ }
87
+
88
+ const isObject = value => typeof value === 'object' && value !== null;
@@ -0,0 +1,22 @@
1
+ // beta, subject to change or deleted
2
+
3
+ export function syncWith(rootItem, object={}) {
4
+ rootItem.addEventListener('changeIn', event => {
5
+ if (!('value' in event)) return;
6
+ const keys = [...event.target.path];
7
+ const lastKey = keys.pop();
8
+ let current = object;
9
+ keys.forEach(key => {
10
+ if (!isObject(current[key])) {
11
+ current[key] = Object.create(null);
12
+ }
13
+ current = current[key];
14
+ });
15
+ current[lastKey] = event.target.value;
16
+ });
17
+ return object;
18
+ }
19
+
20
+ function isObject(obj) {
21
+ return obj === Object(obj);
22
+ }
@@ -0,0 +1,65 @@
1
+
2
+ const operators = {
3
+ 'eq': (a, b) => a === b,
4
+ 'ne': (a, b) => a !== b,
5
+ 'gt': (a, b) => a > b,
6
+ 'gte': (a, b) => a >= b,
7
+ 'lt': (a, b) => a < b,
8
+ 'lte': (a, b) => a <= b,
9
+ 'in': (a, b) => b.includes(a),
10
+ 'nin': (a, b) => !b.includes(a),
11
+ 'exists': (a, b) => (a !== undefined) === b,
12
+ 'includes': (a, b) => String(a).includes(b),
13
+ 'startsWith': (a, b) => String(a).startsWith(b),
14
+ 'endsWith': (a, b) => String(a).endsWith(b),
15
+ 'regex': (a, b) => new RegExp(b).test(String(a)),
16
+ };
17
+
18
+ export function resolveVariables(condition, vars) {
19
+ if (typeof condition === 'string') {
20
+ if (!condition.startsWith('$')) return condition;
21
+ const path = condition.slice(1).split('.');
22
+ return path.reduce((obj, key) => obj?.[key], vars) ?? condition;
23
+ }
24
+ if (Array.isArray(condition)) return condition.map(c => resolveVariables(c, vars));
25
+ if (condition && typeof condition === 'object') {
26
+ return Object.fromEntries(
27
+ Object.entries(condition).map(([k, v]) => [k, resolveVariables(v, vars)])
28
+ );
29
+ }
30
+ return condition;
31
+ }
32
+
33
+ export function matchesCondition(fieldVal, condition, obj) {
34
+ if (!Array.isArray(condition)) return fieldVal === condition; // shorthand eq
35
+ const [op, expected] = condition;
36
+ const resolved = (Array.isArray(expected) && expected[0] === 'ref')
37
+ ? obj[expected[1]]
38
+ : expected;
39
+ return operators[op]?.(fieldVal, resolved) ?? false;
40
+ }
41
+
42
+ // obj → AND über Felder, Array → OR über Einträge
43
+ // onUnknownOperator(condition, obj) → bool — für eigene Operatoren (z.B. cascade)
44
+ export function evaluate(condition, obj, onUnknownOperator) {
45
+ if (condition === true || condition === false) return condition;
46
+ if (condition == null) return false;
47
+
48
+ if (Array.isArray(condition)) {
49
+ const [op] = condition;
50
+ if (typeof op === 'string' && !(op in operators) && op !== 'ref') {
51
+ return onUnknownOperator?.(condition, obj) ?? false;
52
+ }
53
+ // Array → OR
54
+ return condition.some(c => evaluate(c, obj, onUnknownOperator));
55
+ }
56
+
57
+ // Object → AND
58
+ if (typeof condition === 'object' && typeof obj === 'object') {
59
+ return Object.entries(condition).every(([key, cond]) =>
60
+ matchesCondition(obj[key], cond, obj)
61
+ );
62
+ }
63
+
64
+ return false;
65
+ }
@@ -0,0 +1,139 @@
1
+ import { item } from '../item.js';
2
+
3
+ /**
4
+ * Bidirectional sync between Item and CSV format
5
+ * Lightweight CSV parser/stringifier (no dependencies)
6
+ * Similar to jsonDataItem but for CSV files
7
+ *
8
+ * Features:
9
+ * - Auto-sync Item → CSV (debounced)
10
+ * - Auto-sync CSV file → Item (on file change)
11
+ * - Headers from first row
12
+ * - Array of objects structure
13
+ */
14
+ export async function csvDataItem(csvItem, options = {}) {
15
+ const {
16
+ delimiter = ',',
17
+ columns = null, // auto-detect if null
18
+ keyColumn = null, // if set, use this column as item key
19
+ } = options;
20
+
21
+ const root = item();
22
+
23
+ // Parse CSV string to array of objects
24
+ const parseCsv = (csv) => {
25
+ if (!csv || !csv.trim()) return [];
26
+
27
+ const lines = csv.trim().split(/\r?\n/);
28
+ if (lines.length === 0) return [];
29
+
30
+ // Parse a single line respecting quotes
31
+ const parseLine = (line) => {
32
+ const result = [];
33
+ let current = '';
34
+ let inQuotes = false;
35
+
36
+ for (let i = 0; i < line.length; i++) {
37
+ const char = line[i];
38
+ const nextChar = line[i + 1];
39
+
40
+ if (char === '"') {
41
+ if (inQuotes && nextChar === '"') {
42
+ current += '"';
43
+ i++; // Skip next quote
44
+ } else {
45
+ inQuotes = !inQuotes;
46
+ }
47
+ } else if (char === delimiter && !inQuotes) {
48
+ result.push(current);
49
+ current = '';
50
+ } else {
51
+ current += char;
52
+ }
53
+ }
54
+ result.push(current);
55
+ return result;
56
+ };
57
+
58
+ // Get headers
59
+ const csvHeaders = columns || parseLine(lines[0]).map(h => h.trim());
60
+ const dataStart = columns ? 0 : 1;
61
+
62
+ // Parse data rows
63
+ return lines.slice(dataStart).map((line, index) => {
64
+ const values = parseLine(line);
65
+ const row = {};
66
+ csvHeaders.forEach((h, i) => {
67
+ row[h] = values[i] ?? '';
68
+ });
69
+ return row;
70
+ });
71
+ };
72
+
73
+ // Convert array of objects to CSV string
74
+ const stringifyCsv = (data) => {
75
+ if (!Array.isArray(data) || data.length === 0) {
76
+ return columns ? columns.join(delimiter) + '\n' : '';
77
+ }
78
+
79
+ const cols = columns || Object.keys(data[0]);
80
+
81
+ // Escape values containing delimiter, quotes, or newlines
82
+ const escapeValue = (val) => {
83
+ const str = String(val ?? '');
84
+ if (str.includes(delimiter) || str.includes('"') || str.includes('\n')) {
85
+ return '"' + str.replace(/"/g, '""') + '"';
86
+ }
87
+ return str;
88
+ };
89
+
90
+ const lines = [
91
+ cols.join(delimiter),
92
+ ...data.map(row => cols.map(c => escapeValue(row[c])).join(delimiter))
93
+ ];
94
+
95
+ return lines.join('\n');
96
+ };
97
+
98
+ // Sync from CSV to Item
99
+ const syncFromCsv = (csv) => {
100
+ const data = parseCsv(csv);
101
+
102
+ // Clear existing
103
+ for (const child of [...root.items()]) child.remove();
104
+
105
+ // Set new data
106
+ data.forEach((row, index) => {
107
+ const key = keyColumn ? row[keyColumn] : String(index);
108
+ if (key !== undefined && key !== null) {
109
+ root.item(String(key)).set(row);
110
+ }
111
+ });
112
+ };
113
+
114
+ // Sync from Item to CSV
115
+ const syncToCsv = () => {
116
+ const data = [...root.items()].map(child => child.value);
117
+ const csv = stringifyCsv(data);
118
+ csvItem.set(csv);
119
+ };
120
+
121
+ // Initial sync
122
+ syncFromCsv(await csvItem.read());
123
+
124
+ // Item → CSV (debounced)
125
+ root.addEventListener('changeIn', debounce(syncToCsv, 100));
126
+
127
+ // CSV → Item (on external change)
128
+ csvItem.addEventListener('change', () => syncFromCsv(csvItem.value));
129
+
130
+ return root;
131
+ }
132
+
133
+ function debounce(fn, ms) {
134
+ let timer;
135
+ return (...args) => {
136
+ clearTimeout(timer);
137
+ timer = setTimeout(() => fn(...args), ms);
138
+ };
139
+ }
@@ -0,0 +1,27 @@
1
+ // Bind a dialect descriptor + an injected runner into a small query handle.
2
+ // The runner ({ query, exec, transaction }) is connection-aware but dialect-dumb;
3
+ // tools/db never imports a database client. Everything speaks only sql`` fragments.
4
+ import { render, resolveSql } from './sql.js';
5
+
6
+ /** @param dialect descriptor @param runner { query(text,params), exec(text,params), transaction(fn) } */
7
+ export function connect(dialect, runner) {
8
+ const query = async (frag) => {
9
+ await resolveSql(frag);
10
+ const { text, params } = render(frag, dialect);
11
+ return runner.query(text, params);
12
+ };
13
+ const exec = async (frag, { returning } = {}) => {
14
+ if (returning && dialect.appendReturning) frag = dialect.appendReturning(frag, returning);
15
+ await resolveSql(frag);
16
+ const { text, params } = render(frag, dialect);
17
+ return runner.exec(text, params);
18
+ };
19
+ return {
20
+ dialect,
21
+ query,
22
+ exec,
23
+ row: (frag) => query(frag).then((rows) => rows[0]),
24
+ columns: (table) => dialect.columns(runner.query, table),
25
+ transaction: (fn) => runner.transaction(fn),
26
+ };
27
+ }
@@ -0,0 +1,12 @@
1
+ // MySQL dialect descriptor — pure SQL knowledge, no connection.
2
+ import { schemaFromDb } from '../../schema/db/mysql/from-db.js';
3
+ import { makeColumns } from '../shared.js';
4
+
5
+ export const mysql = {
6
+ name: 'mysql',
7
+ quoteId: (name) => `\`${String(name).replaceAll('`', '``')}\``,
8
+ placeholder: () => '?',
9
+ emptyInsert: '() VALUES ()',
10
+ insertId: 'lastId',
11
+ columns: makeColumns(schemaFromDb),
12
+ };
@@ -0,0 +1,15 @@
1
+ // PostgreSQL dialect descriptor — pure SQL knowledge, no connection.
2
+ import { schemaFromDb } from '../../schema/db/pg/from-db.js';
3
+ import { makeColumns } from '../shared.js';
4
+ import { sql } from '../sql.js';
5
+
6
+ export const pg = {
7
+ name: 'postgres',
8
+ quoteId: (name) => `"${String(name).replaceAll('"', '""')}"`,
9
+ placeholder: (n) => '$' + n,
10
+ emptyInsert: 'DEFAULT VALUES',
11
+ insertId: 'returning',
12
+ // pg has no lastInsertId; the id comes back via RETURNING (used by connect.exec).
13
+ appendReturning: (frag, col) => sql`${frag} RETURNING ${sql.id(col)}`,
14
+ columns: makeColumns(schemaFromDb),
15
+ };
@@ -0,0 +1,12 @@
1
+ // SQLite dialect descriptor — pure SQL knowledge, no connection.
2
+ import { schemaFromDb } from '../../schema/db/sqlite/from-db.js';
3
+ import { makeColumns } from '../shared.js';
4
+
5
+ export const sqlite = {
6
+ name: 'sqlite',
7
+ quoteId: (name) => `"${String(name).replaceAll('"', '""')}"`,
8
+ placeholder: () => '?',
9
+ emptyInsert: 'DEFAULT VALUES',
10
+ insertId: 'lastId',
11
+ columns: makeColumns(schemaFromDb),
12
+ };
@@ -0,0 +1,7 @@
1
+ // Helpers shared across dialect descriptors.
2
+
3
+ /** Build a `columns(query, table)` that delegates introspection to a schema from-db. */
4
+ export const makeColumns = (schemaFromDb) => async (query, table) => {
5
+ const schema = await schemaFromDb(query);
6
+ return schema.properties[table]?.additionalProperties?.properties ?? {};
7
+ };
@@ -0,0 +1,24 @@
1
+ export type SqlPart = { text: string } | { param: unknown } | { id: unknown };
2
+
3
+ export class Sql {
4
+ parts: SqlPart[];
5
+ constructor(parts?: SqlPart[]);
6
+ }
7
+
8
+ export interface SqlTag {
9
+ (strings: TemplateStringsArray, ...values: unknown[]): Sql;
10
+ id(name: string | { toString(): string } | Promise<string | { toString(): string }>): Sql;
11
+ raw(text: string): Sql;
12
+ join(frags: Sql[], separator?: string): Sql;
13
+ }
14
+
15
+ export const sql: SqlTag;
16
+
17
+ export function resolveSql(frag: Sql): Promise<void>;
18
+
19
+ export function render(
20
+ frag: Sql,
21
+ dialect: { quoteId(name: string): string; placeholder(n: number): string },
22
+ ): { text: string; params: unknown[] };
23
+
24
+ export function isTemplate(a: unknown): a is TemplateStringsArray;
@@ -0,0 +1,83 @@
1
+ // @ts-self-types="./sql.d.ts"
2
+ // Dialect-neutral SQL fragments. Build with the sql`` tag — interpolated values
3
+ // become bound params, nested fragments/ids compose. A driver renders the AST to
4
+ // its dialect (placeholder + identifier quoting) at execution time. Pure data, no dialect.
5
+
6
+ export class Sql {
7
+ /** @param {Array<{text:string}|{param:unknown}|{id:string}>} parts */
8
+ constructor(parts = []) { this.parts = parts; }
9
+ }
10
+
11
+ /** Tagged template; interpolated values become bound params, nested Sql composes. */
12
+ export function sql(strings, ...values) {
13
+ const parts = [];
14
+ strings.forEach((s, i) => {
15
+ if (s) parts.push({ text: s });
16
+ if (i < values.length) {
17
+ const v = values[i];
18
+ if (v instanceof Sql) parts.push(...v.parts);
19
+ else parts.push({ param: v });
20
+ }
21
+ });
22
+ return new Sql(parts);
23
+ }
24
+
25
+ /** Quote a dynamic identifier (table/column) per dialect. */
26
+ sql.id = (name) => new Sql([{ id: name }]);
27
+
28
+ /** Inject text verbatim — escape hatch, never pass user input. */
29
+ sql.raw = (text) => new Sql([{ text }]);
30
+
31
+ /** Join fragments with a separator (IN-lists, column sets). */
32
+ sql.join = (frags, separator = ', ') => {
33
+ const parts = [];
34
+ frags.forEach((f, i) => {
35
+ if (i) parts.push({ text: separator });
36
+ parts.push(...f.parts);
37
+ });
38
+ return new Sql(parts);
39
+ };
40
+
41
+ // Objects with their own toString bind as strings; plain objects/arrays are
42
+ // mistakes (drivers would mangle them, e.g. mysql2's `key`='value' expansion).
43
+ function toParam(v) {
44
+ if (v === null || typeof v !== 'object' || v instanceof Date || v instanceof Uint8Array) return v;
45
+ if (Array.isArray(v) || v.toString === Object.prototype.toString) {
46
+ throw new TypeError('sql: cannot bind ' + (Array.isArray(v) ? 'an array' : 'a plain object') + ' as parameter');
47
+ }
48
+ return String(v);
49
+ }
50
+
51
+ // Lower an AST to { text, params } for a dialect. Stays dialect-agnostic: the
52
+ // dialect (quoteId + placeholder) is passed in, never imported here.
53
+ /** @param {Sql} frag @param {{quoteId(name:string):string, placeholder(n:number):string}} dialect */
54
+ export function render(frag, dialect) {
55
+ let text = '';
56
+ const params = [];
57
+ for (const part of frag.parts) {
58
+ if ('text' in part) text += part.text;
59
+ else if ('id' in part) text += dialect.quoteId(String(part.id));
60
+ else { params.push(toParam(part.param)); text += dialect.placeholder(params.length); }
61
+ }
62
+ return { text, params };
63
+ }
64
+
65
+ /** Await promised params/ids in place, in parallel; a promise resolving to a
66
+ * Sql fragment composes. Run by the query layer before render. */
67
+ export async function resolveSql(frag) {
68
+ if (!frag.parts.some(p => p.param instanceof Promise || p.id instanceof Promise)) return;
69
+ frag.parts = (await Promise.all(frag.parts.map(async p => {
70
+ if (p.id instanceof Promise) return [{ id: await p.id }];
71
+ if (!(p.param instanceof Promise)) return [p];
72
+ const v = await p.param;
73
+ if (v instanceof Sql) { await resolveSql(v); return v.parts; }
74
+ return [{ param: v }];
75
+ }))).flat();
76
+ }
77
+
78
+ // Companion to the sql`` tag: lets a query API accept either a tagged template
79
+ // or a pre-built Sql fragment, and dispatch on which it got.
80
+ /** True for a tagged-template call (first arg is the template strings array). */
81
+ export function isTemplate(a) {
82
+ return Array.isArray(a?.raw);
83
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * HTTP router for Item structures
3
+ * REST API: GET/POST/PUT/PATCH/DELETE/OPTIONS
4
+ */
5
+
6
+ /**
7
+ * Creates a router handler that responds to HTTP requests for Item access
8
+ * @param {Item} rootItem - The root Item instance to expose
9
+ * @param {string} [basePath=''] - Base path to listen on (e.g. '/api')
10
+ * @returns {Function} Async handler (request) => Response
11
+ */
12
+ export function createItemRouter(rootItem, basePath = '') {
13
+ const prefixParts = basePath.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
14
+
15
+ const methods = {
16
+ async GET({ path, url }) {
17
+ const item = rootItem.sub(path);
18
+ if (url.searchParams.has('$schema')) return jsonResponse(item.schema ?? null);
19
+ const query = url.searchParams.has('q') ? JSON.parse(url.searchParams.get('q')) : null;
20
+ await item.read(query);
21
+ const value = item.get({ depth: query?.depth ?? 1 });
22
+ return jsonResponse({value, depth: query?.depth ?? 1});
23
+ },
24
+ async PUT({ path, request }) {
25
+ await rootItem.sub(path).set(await request.json());
26
+ return jsonResponse({ success: true });
27
+ },
28
+ async PATCH({ path, request }) {
29
+ await rootItem.sub(path).patch(await request.json());
30
+ return jsonResponse({ success: true });
31
+ },
32
+ async DELETE({ path }) {
33
+ await rootItem.has(path)?.remove();
34
+ return new Response(null, { status: 204 });
35
+ },
36
+ async POST({ path, request }) {
37
+ const newItem = await rootItem.sub(path).add(await request.json());
38
+ return jsonResponse({ success: true, key: newItem.key });
39
+ },
40
+ async OPTIONS() {
41
+ const headers = new Headers({'Access-Control-Max-Age': '86400'});
42
+ return new Response(null, { status: 204, headers });
43
+ },
44
+ };
45
+
46
+ return async (request) => {
47
+ try {
48
+ const url = new URL(request.url);
49
+ const segments = url.pathname.split('/').filter(Boolean);
50
+ if (prefixParts.some((p, i) => segments[i] !== p)) return null;
51
+ const path = segments.slice(prefixParts.length);
52
+ const handler = methods[request.method];
53
+ if (!handler) return jsonResponse({ error: 'Method not allowed' }, 405);
54
+ return await handler({ path, request, url });
55
+ } catch (err) {
56
+ return jsonResponse({ error: err.message }, 400);
57
+ }
58
+ };
59
+ }
60
+
61
+ function jsonResponse(data, status = 200) {
62
+ return new Response(JSON.stringify(data), {
63
+ status,
64
+ headers: { 'Content-Type': 'application/json' },
65
+ });
66
+ }
@@ -0,0 +1,29 @@
1
+ import { item } from '../item.js';
2
+
3
+ export async function jsonDataItem(jsonItem) {
4
+ const root = item();
5
+ const syncFromJson = (json) => root.set(json ? JSON.parse(json) : null);
6
+ const syncToJson = () => jsonItem.set(JSON.stringify(root.value, null, 2));
7
+ await jsonItem.read();
8
+ syncFromJson(jsonItem.get());
9
+ root.addEventListener('changeIn', debounce(syncToJson, 25));
10
+ jsonItem.addEventListener('change', () => syncFromJson(jsonItem.value));
11
+ return root;
12
+ }
13
+
14
+ /** JSON-backed root item: every write in the tree persists the whole document.
15
+ * `set()`/`patch()` return a promise — await it when the write must have landed. */
16
+ export function bildJsonItem(raw, save, options = {}) {
17
+ const root = item(JSON.parse(raw || "{}"));
18
+ root.writer = v => save(JSON.stringify(v));
19
+ root.io.options.debounceMs = options.debounce ?? 25;
20
+ return root;
21
+ }
22
+
23
+ function debounce(fn, ms) {
24
+ let timer;
25
+ return (...args) => {
26
+ clearTimeout(timer);
27
+ timer = setTimeout(() => fn(...args), ms);
28
+ };
29
+ }