@nuxodin/item 0.6.18 → 0.6.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxodin/item",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Reactive state library",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,13 +5,14 @@
5
5
  set: value => fetch('https://example.com/todos/1', {method: 'PUT', body: JSON.stringify(value)}).then(res => res.json())
6
6
  });
7
7
  datapoint.set({title: 'foo', completed: true}); // Returns promise - or undefined if value is already the same - do not rely on return value
8
+ // a write overtaken by a newer one settles as that one does: "this value or a newer one is saved"
8
9
  datapoint.get().then(value => console.log(value));
9
10
 
10
11
  // API
11
12
  datapoint.onchange = ({value}) => console.log('value changed', value);
12
- datapoint.ttl = 1000; // cache for 1 second
13
- datapoint.optimistic = true; // trust sending value: until the sending is done, the value is the new value, despite the uncertainty that the server will fail
14
- datapoint.debounceMs = 5; // debounce period for setter in ms, default 5
13
+ // options, passed to the constructor (or changed in datapoint.options):
14
+ // ttl: 5000 (cache in ms), debounceMs: 5 (setter delay), getRetry / setRetry, getRetryDelay / setRetryDelay,
15
+ // optimistic: true (until the sending is done, the value is the new one, though the server may fail)
15
16
  datapoint.setLocal({title: 'foo', completed: true}); // set value without saving it to the master (the value comes from the master through an other channel / trusted source)
16
17
  */
17
18
 
@@ -21,6 +22,7 @@
21
22
  createSetter;
22
23
  #expectedValue = null; // value while saving
23
24
  #setter = null; // current setter promise
25
+ #writing = Promise.resolve(); // the write running, the next one starts after it
24
26
  #getter = null; // current/cached getter promise
25
27
  #getterExpiry = Infinity; // ttl is checked lazily on access — no timer per cache entry
26
28
  // expired getters count as gone (lazy ttl)
@@ -62,11 +64,15 @@
62
64
  #createSetter(value) {
63
65
  const {setRetry, setRetryDelay, debounceMs} = this.options;
64
66
  const promise = abortablePromise((resolve, reject) => {
65
- retryAsync(
66
- () => this.createSetter(value, promise.controller.signal),
67
+ const {signal} = promise.controller;
68
+ // one write at a time: a slow one landing last would leave the remote with an old value
69
+ const run = this.#writing.then(() => signal.aborted || retryAsync(
70
+ () => this.createSetter(value, signal),
67
71
  setRetry,
68
72
  setRetryDelay
69
- ).then(resolve, reject);
73
+ ));
74
+ this.#writing = run.catch(() => {});
75
+ run.then(resolve, reject);
70
76
  }, debounceMs);
71
77
 
72
78
  makePromiseTransparent(promise);
@@ -83,18 +89,12 @@
83
89
  promise.then(
84
90
  value => {
85
91
  if (this.#getter !== promise) return;
86
- if (!isEqual(oldValue, value)) {
87
- try {
88
- this.onchange?.({value, oldValue});
89
- } catch (err) {
90
- console.error('[item.js] AsyncDataPoint onchange error:', err);
91
- }
92
- }
92
+ if (!isEqual(oldValue, value)) this.#notify({value, oldValue});
93
93
  },
94
94
  error => {
95
95
  if (this.#getter !== promise) return;
96
96
  if (error?.name === 'AbortError') return;
97
- this.onchange?.({error, oldValue});
97
+ this.#notify({error, oldValue});
98
98
  }
99
99
  );
100
100
 
@@ -103,6 +103,11 @@
103
103
  this.#getterExpiry = duration > 0 ? Date.now() + duration : Infinity;
104
104
  }
105
105
 
106
+ #notify(event) {
107
+ try { this.onchange?.(event); }
108
+ catch (err) { console.error('[item.js] AsyncDataPoint onchange error:', err); }
109
+ }
110
+
106
111
  setLocal(value) { this.#cacheGetter(transparentPromiseResolve(value)); }
107
112
  setFromPromise(promise) { this.#cacheGetter(makePromiseTransparent(promise)); }
108
113
  get isPending() { return this.#cachedGetter?.state === 'pending' || this.#setter?.state === 'pending'; }
@@ -124,18 +129,18 @@
124
129
  }
125
130
 
126
131
  set(value) {
132
+ const previous = this.#setter, writing = previous?.state === 'pending';
127
133
  // Already setting this value
128
- if (this.#setter?.state === 'pending' && isEqual(this.#expectedValue, value)) return this.#setter;
129
-
130
- this.#setter?.controller.abort(); // abort previous setter
134
+ if (writing && isEqual(this.#expectedValue, value)) return previous;
131
135
 
132
- // ignore if latest getter value is the same
133
136
  // Value already set (no-op). Returns undefined to avoid falsy issues (value could be 0, false, etc.)
137
+ // While a write runs the loaded value is outdated: setting it back is a change.
134
138
  const cached = this.#cachedGetter;
135
- if (cached?.state === 'fulfilled' && isEqual(cached.value, value)) return;
139
+ if (!writing && cached?.state === 'fulfilled' && isEqual(cached.value, value)) return;
136
140
 
137
141
  this.#expectedValue = value;
138
142
  const promise = this.#createSetter(value);
143
+ previous?.controller.abort(promise); // the previous one settles as this one does
139
144
 
140
145
  this.onpending?.();
141
146
 
@@ -146,13 +151,12 @@
146
151
  this.#setter = null;
147
152
  this.#expectedValue = null;
148
153
  },
149
- error => {
154
+ error => { // the failed setter stays: lastError reports it until the next write
150
155
  if (this.#setter !== promise) return;
151
156
  if (error?.name === 'AbortError') return;
152
157
  this.#getter = null;
153
- this.#setter = null;
154
158
  this.#expectedValue = null;
155
- this.onchange?.({ error }); }
159
+ this.#notify({ error }); }
156
160
  );
157
161
 
158
162
  return this.#setter = promise;
@@ -223,18 +227,21 @@
223
227
  }
224
228
 
225
229
  /**
226
- * Returns a delayed Promise that can be aborted within the specified time (ms).
227
- * @param {Promise<any>} fn - A function that returns a Promise.
228
- * @param {Number} [ms=1] - The time in milliseconds to delay before resolving the Promise.
230
+ * Returns a delayed Promise that can be aborted. Aborted with a promise as reason, it settles as
231
+ * that one does (a newer write taking over), else it rejects with the AbortError.
232
+ * @param {Function} fn - Called with (resolve, reject) after the delay.
233
+ * @param {Number} [ms=1] - The time in milliseconds to delay before calling fn.
229
234
  * @returns {Promise<any>} - A delayed Promise that can be aborted.
230
235
  */
231
236
  function abortablePromise(fn, ms=1) { // delayed and therfore abortable within ms
232
237
  const controller = new AbortController();
238
+ const {signal} = controller;
233
239
  const promise = new Promise((resolve, reject) => {
234
- setTimeout(() => {
235
- if (controller.signal.aborted) return reject(new DOMException('aborted', 'AbortError'));
236
- fn(resolve, reject);
237
- }, ms);
240
+ const settle = (ok) => (v) => {
241
+ if (!signal.aborted) return ok(v);
242
+ signal.reason?.then ? resolve(signal.reason) : reject(signal.reason);
243
+ };
244
+ setTimeout(() => signal.aborted ? settle()() : fn(settle(resolve), settle(reject)), ms);
238
245
  });
239
246
  promise.controller = controller;
240
247
  return promise;
@@ -40,10 +40,7 @@ function secondaryIndexKind(prop) {
40
40
 
41
41
  function usableIndexKind(table, name, prop) {
42
42
  const kind = secondaryIndexKind(prop);
43
- const issue = kind && indexIssue(table, name, prop, kind);
44
- if (!issue) return kind;
45
- console.warn(issue);
46
- return null;
43
+ return kind && !indexIssue(table, name, prop, kind) ? kind : null;
47
44
  }
48
45
 
49
46
  function indexIssue(table, name, prop, kind) {
@@ -109,6 +106,7 @@ export async function schemaToDb(
109
106
  }
110
107
 
111
108
  const stmts = [];
109
+ const warnings = [];
112
110
  const nextTables = Object.keys(schema.properties ?? {});
113
111
  const currTables = Object.keys(current.properties ?? {});
114
112
 
@@ -117,6 +115,11 @@ export async function schemaToDb(
117
115
  const { fields, required } = tableData(schema.properties[table]);
118
116
  const primaries = primaryFields(fields);
119
117
  for (const p of primaries) required.add(p); // primary key columns are always NOT NULL
118
+ for (const [n, f] of fields) {
119
+ const kind = secondaryIndexKind(f);
120
+ const issue = kind && indexIssue(table, n, f, kind);
121
+ if (issue) warnings.push(issue); // the index is left out
122
+ }
120
123
 
121
124
  if (!currTables.includes(table)) {
122
125
  const cols = fields.map(([n, f]) =>
@@ -196,11 +199,8 @@ export async function schemaToDb(
196
199
  }
197
200
  }
198
201
 
199
- for (const stmt of stmts) {
200
- console.log(stmt);
201
- await query(stmt);
202
- }
203
- return { diffs, executed: stmts };
202
+ for (const stmt of stmts) await query(stmt);
203
+ return { diffs, executed: stmts, warnings };
204
204
  }
205
205
 
206
206
  async function indexStatements(query, table, nextFields, currFields, { patch = false } = {}) {
@@ -32,13 +32,11 @@ function preserveDefault(prop, currProp) {
32
32
  : { ...prop, default: currProp.default }
33
33
  }
34
34
 
35
- function secondaryIndexKind(table, name, prop) {
35
+ function secondaryIndexKind(prop) {
36
36
  // hnsw needs a fixed length and an operator class; the application builds vector indexes
37
37
  if (prop['x-vector']) return null
38
38
  if (prop['x-index'] === 'unique') return 'UNIQUE INDEX'
39
39
  if (prop['x-index'] === true) return 'INDEX'
40
- if (prop['x-index'] === 'fulltext')
41
- console.warn(`Skip FULLTEXT INDEX ${table}.${name}: PostgreSQL fulltext requires an expression index`)
42
40
  return null
43
41
  }
44
42
 
@@ -114,6 +112,7 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
114
112
  }
115
113
 
116
114
  const stmts = []
115
+ const warnings = []
117
116
  const nextTables = Object.keys(schema.properties ?? {})
118
117
  const currTables = Object.keys(current.properties ?? {})
119
118
 
@@ -122,6 +121,10 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
122
121
  const { fields, required } = tableData(schema.properties[table])
123
122
  const primaries = primaryFields(fields)
124
123
  for (const p of primaries) required.add(p)
124
+ for (const [n, f] of fields) {
125
+ if (f['x-index'] === 'fulltext' && !f['x-vector'])
126
+ warnings.push(`Skip FULLTEXT INDEX ${table}.${n}: PostgreSQL fulltext requires an expression index`)
127
+ }
125
128
 
126
129
  if (!currTables.includes(table)) {
127
130
  const cols = fields.map(([n, f]) =>
@@ -135,7 +138,7 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
135
138
  for (const [n, f] of fields) if (f['$comment']) stmts.push(commentStmt(table, n, f['$comment']))
136
139
  for (const [n, f] of fields) {
137
140
  if (primarySet.has(n)) continue
138
- const kind = secondaryIndexKind(table, n, f)
141
+ const kind = secondaryIndexKind(f)
139
142
  if (kind) stmts.push(createIndex(table, n, kind))
140
143
  }
141
144
  } else {
@@ -177,11 +180,8 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
177
180
  const vectors = nextTables.some(t => tableData(schema.properties[t]).fields.some(([, f]) => f['x-vector']))
178
181
  if (vectors && stmts.length) stmts.unshift('CREATE EXTENSION IF NOT EXISTS vector;')
179
182
 
180
- for (const stmt of stmts) {
181
- console.log(stmt)
182
- await query(stmt)
183
- }
184
- return { diffs, executed: stmts }
183
+ for (const stmt of stmts) await query(stmt)
184
+ return { diffs, executed: stmts, warnings }
185
185
  }
186
186
 
187
187
  async function indexStatements(query, table, nextFields, currFields, { patch = false } = {}) {
@@ -198,7 +198,7 @@ async function indexStatements(query, table, nextFields, currFields, { patch = f
198
198
 
199
199
  for (const [name, prop] of nextFields) {
200
200
  if (nextPrimary.has(name)) continue
201
- const nextKind = secondaryIndexKind(table, name, prop)
201
+ const nextKind = secondaryIndexKind(prop)
202
202
  const curr = indexes.secondary.find(i => i.column === name)
203
203
  const currKind = curr ? curr.unique ? 'UNIQUE INDEX' : 'INDEX' : null
204
204
  const changed = curr && currKind !== nextKind
@@ -209,7 +209,7 @@ async function indexStatements(query, table, nextFields, currFields, { patch = f
209
209
 
210
210
  if (!patch) {
211
211
  for (const curr of indexes.secondary) {
212
- const wanted = nextFields.find(([name, prop]) => name === curr.column && secondaryIndexKind(table, name, prop))
212
+ const wanted = nextFields.find(([name, prop]) => name === curr.column && secondaryIndexKind(prop))
213
213
  if (!wanted) before.push(`DROP INDEX ${quoteId(curr.key)};`)
214
214
  }
215
215
  }
@@ -34,8 +34,6 @@ function createBody(table, fields, primaries) {
34
34
  const solo = soloIntPrimary(fields, primaries)
35
35
  const cols = fields.map(([n, f]) => {
36
36
  const isPk = n === solo
37
- if (f['x-autoincrement'] && !isPk)
38
- console.warn(`Skip AUTOINCREMENT ${table}.${n}: SQLite auto-increments only a sole INTEGER PRIMARY KEY — nothing fills this column`)
39
37
  // Only `INTEGER PRIMARY KEY` is the rowid alias, and AUTOINCREMENT is legal nowhere else —
40
38
  // so a boolean key keeps the integer spelling, which SQLite would otherwise reject.
41
39
  let def = toFieldDef(n, isPk ? { ...f, type: 'integer' } : f)
@@ -50,11 +48,17 @@ function indexKind(table, name, prop) {
50
48
  if (prop['x-vector']) return null // sqlite-vec scans; a B-tree over vectors helps nothing
51
49
  if (prop['x-index'] === 'unique') return 'UNIQUE INDEX'
52
50
  if (prop['x-index'] === true) return 'INDEX'
53
- if (prop['x-index'] === 'fulltext')
54
- console.warn(`Skip FULLTEXT INDEX ${table}.${name}: SQLite fulltext requires a virtual FTS table`)
55
51
  return null
56
52
  }
57
53
 
54
+ /** What the schema asks of a field that SQLite cannot do; the field is created without it. */
55
+ function fieldWarning(table, name, prop, solo) {
56
+ if (prop['x-autoincrement'] && name !== solo)
57
+ return `Skip AUTOINCREMENT ${table}.${name}: SQLite auto-increments only a sole INTEGER PRIMARY KEY — nothing fills this column`
58
+ if (prop['x-index'] === 'fulltext' && !prop['x-vector'])
59
+ return `Skip FULLTEXT INDEX ${table}.${name}: SQLite fulltext requires a virtual FTS table`
60
+ }
61
+
58
62
  function createIndex(table, name, kind) {
59
63
  return `CREATE ${kind} ${quoteId(indexName(table, name))} ON ${quoteId(table)} (${quoteId(name)});`
60
64
  }
@@ -94,12 +98,18 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
94
98
  }
95
99
 
96
100
  const stmts = []
101
+ const warnings = []
97
102
  const nextTables = Object.keys(schema.properties ?? {})
98
103
  const currTables = Object.keys(current.properties ?? {})
99
104
 
100
105
  for (const table of nextTables) {
101
106
  const fields = tableFields(schema.properties[table])
102
107
  const primaries = fields.filter(([, f]) => f['x-index'] === 'primary').map(([n]) => n)
108
+ const solo = soloIntPrimary(fields, primaries)
109
+ for (const [n, f] of fields) {
110
+ const warning = fieldWarning(table, n, f, solo)
111
+ if (warning) warnings.push(warning)
112
+ }
103
113
 
104
114
  if (!currTables.includes(table)) {
105
115
  // Idea: WITHOUT ROWID when the primary key is not a single integer column and all other columns
@@ -144,9 +154,6 @@ export async function schemaToDb(schema, query, { force = false, patch = false }
144
154
  }
145
155
  }
146
156
 
147
- for (const stmt of stmts) {
148
- console.log(stmt)
149
- await query(stmt)
150
- }
151
- return { diffs, executed: stmts }
157
+ for (const stmt of stmts) await query(stmt)
158
+ return { diffs, executed: stmts, warnings }
152
159
  }