@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
@@ -0,0 +1,311 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Documents on a Node filesystem: the readers that hand a
4
+ * migration one document at a time, and the writer that replaces a file
5
+ * only once every document has survived every step.
6
+ *
7
+ * Two encodings, both walked without holding the collection. **JSONL**
8
+ * is one document per line, so a line boundary is a document boundary.
9
+ * **JSON** is one top-level array, which has no line structure at all —
10
+ * so it is scanned structurally: a character-code walk tracks string,
11
+ * escape and nesting depth and hands back each element's text as it
12
+ * closes, which is the only way to read the second element of a 100 MiB
13
+ * array without parsing the first 99. Neither reader ever holds more
14
+ * than one document plus the chunk it is decoding.
15
+ *
16
+ * The writer is the reason a failed migration cannot corrupt a file. It
17
+ * writes a sibling temporary, flushes it, and renames only when the
18
+ * caller commits — a rename within a directory is atomic, so a reader
19
+ * sees the old bytes or the new ones and never a half-written mix. Any
20
+ * failure aborts instead: the temporary is removed and the original is
21
+ * left byte for byte as it was.
22
+ *
23
+ * Node-only, and reached through `@jarenjs/db/node`: `@jarenjs/db`
24
+ * itself imports no `node:` module, because it runs in a browser too.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import * as fsp from 'node:fs/promises';
29
+ import * as path from 'node:path';
30
+ import { StringDecoder } from 'node:string_decoder';
31
+
32
+ import { DbCompileError } from './errors.js';
33
+
34
+ /** The document encodings a file may carry. */
35
+ export const DOCUMENT_FORMATS = Object.freeze(['json', 'jsonl']);
36
+
37
+ /**
38
+ * The encoding a path declares by its extension. `.jsonl`/`.ndjson` are
39
+ * line-delimited; everything else is one JSON array.
40
+ * @param {string} file
41
+ * @returns {'json' | 'jsonl'}
42
+ */
43
+ export function formatOf(file) {
44
+ const extension = path.extname(file).toLowerCase();
45
+ return extension === '.jsonl' || extension === '.ndjson' ? 'jsonl' : 'json';
46
+ }
47
+
48
+ const refuse = (reason) => new DbCompileError('JD0024', reason);
49
+
50
+ /**
51
+ * A readable of byte chunks decoded into strings, from a path or from
52
+ * an already-open stream (`-` is standard input).
53
+ * @param {string | AsyncIterable<any>} source
54
+ * @returns {AsyncIterable<string>}
55
+ */
56
+ async function* textChunks(source) {
57
+ const stream = typeof source === 'string'
58
+ ? fs.createReadStream(source)
59
+ : source;
60
+ const decoder = new StringDecoder('utf8');
61
+ for await (const chunk of stream) {
62
+ const text = decoder.write(/** @type {Buffer} */ (chunk));
63
+ if (text !== '') yield text;
64
+ }
65
+ const rest = decoder.end();
66
+ if (rest !== '') yield rest;
67
+ }
68
+
69
+ /**
70
+ * The documents of a JSONL source, one line at a time.
71
+ * @param {string | AsyncIterable<any>} source
72
+ * @returns {AsyncGenerator<any>}
73
+ */
74
+ export async function* readJsonlDocuments(source) {
75
+ let pending = '';
76
+ let line = 0;
77
+ for await (const chunk of textChunks(source)) {
78
+ pending += chunk;
79
+ let at = pending.indexOf('\n');
80
+ while (at >= 0) {
81
+ const text = pending.slice(0, at).trim();
82
+ pending = pending.slice(at + 1);
83
+ line++;
84
+ if (text !== '') yield parseDocument(text, `line ${line}`);
85
+ at = pending.indexOf('\n');
86
+ }
87
+ }
88
+ const last = pending.trim();
89
+ if (last !== '') yield parseDocument(last, `line ${line + 1}`);
90
+ }
91
+
92
+ /** One document's text, with the position a failure names. */
93
+ function parseDocument(text, where) {
94
+ try {
95
+ return JSON.parse(text);
96
+ }
97
+ catch (cause) {
98
+ throw refuse(`${where} is not JSON: ${/** @type {Error} */ (cause).message}`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The documents of a top-level JSON array, scanned structurally so that
104
+ * the array is never held whole. Anything but an array at the root is
105
+ * refused: a migration runs over a collection, not over one document.
106
+ * @param {string | AsyncIterable<any>} source
107
+ * @returns {AsyncGenerator<any>}
108
+ */
109
+ export async function* readJsonDocuments(source) {
110
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
111
+ let started = false;
112
+ let closed = false;
113
+ let element = '';
114
+ let depth = 0;
115
+ let inString = false;
116
+ let escaped = false;
117
+ let index = 0;
118
+ let separator = false;
119
+ let allowEnd = true;
120
+
121
+ for await (const chunk of textChunks(source)) {
122
+ for (let i = 0; i < chunk.length; i++) {
123
+ const character = chunk[i];
124
+ if (!started) {
125
+ if (isSpace(character)) continue;
126
+ if (character !== '[') {
127
+ throw refuse('a JSON document file must hold one top-level ARRAY of '
128
+ + `documents; this one starts with '${character}'`);
129
+ }
130
+ started = true;
131
+ continue;
132
+ }
133
+ if (closed) {
134
+ if (isSpace(character)) continue;
135
+ throw refuse(`trailing content after the closing ']' (found '${character}')`);
136
+ }
137
+ if (element === '') {
138
+ if (isSpace(character)) continue;
139
+ if (separator) {
140
+ if (character === ']') { closed = true; continue; }
141
+ if (character !== ',') throw refuse(`expected ',' or ']' after document ${index - 1}`);
142
+ separator = false;
143
+ allowEnd = false;
144
+ continue;
145
+ }
146
+ if (character === ']' && allowEnd) { closed = true; continue; }
147
+ if (character === ',' || character === ']')
148
+ throw refuse(`expected a document before '${character}'`);
149
+ element = character;
150
+ if (character === '"') inString = true;
151
+ else if (character === '{' || character === '[') depth = 1;
152
+ continue;
153
+ }
154
+ if (inString) {
155
+ element += character;
156
+ if (escaped) escaped = false;
157
+ else if (character === '\\') escaped = true;
158
+ else if (character === '"') {
159
+ inString = false;
160
+ // a top-level string element ends with its closing quote
161
+ if (depth === 0) {
162
+ yield parseDocument(element, `document ${index++}`);
163
+ element = '';
164
+ separator = true;
165
+ }
166
+ }
167
+ continue;
168
+ }
169
+ if (depth === 0) {
170
+ // a scalar element (number, true, false, null): it ends at the
171
+ // next separator, which belongs to the array and not to it
172
+ if (isSpace(character) || character === ',' || character === ']') {
173
+ yield parseDocument(element, `document ${index++}`);
174
+ element = '';
175
+ separator = true;
176
+ i--; // the array's separator is checked on the next iteration
177
+ continue;
178
+ }
179
+ element += character;
180
+ continue;
181
+ }
182
+ element += character;
183
+ if (character === '"') { inString = true; continue; }
184
+ if (character === '{' || character === '[') { depth++; continue; }
185
+ if (character === '}' || character === ']') {
186
+ depth--;
187
+ if (depth === 0) {
188
+ yield parseDocument(element, `document ${index++}`);
189
+ element = '';
190
+ separator = true;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ if (!started) throw refuse('the source is empty: a JSON document file holds one array');
196
+ if (!closed) throw refuse('the array is never closed — the source ends inside it');
197
+ }
198
+
199
+ /**
200
+ * The documents of a file or stream in the named encoding.
201
+ * @param {string | AsyncIterable<any>} source
202
+ * @param {'json' | 'jsonl'} format
203
+ * @returns {AsyncGenerator<any>}
204
+ */
205
+ export function readDocuments(source, format) {
206
+ if (format === 'jsonl') return readJsonlDocuments(source);
207
+ if (format === 'json') return readJsonDocuments(source);
208
+ throw refuse(`unknown document format '${format}' — one of ${DOCUMENT_FORMATS.join(', ')}`);
209
+ }
210
+
211
+ /**
212
+ * A sink that publishes whole or not at all.
213
+ *
214
+ * `write` appends to a sibling temporary; `commit` flushes it, renames
215
+ * it over the target and answers the bytes written; `abort` removes it
216
+ * and leaves the target untouched. A target that is never committed is
217
+ * a target that never changed.
218
+ *
219
+ * @param {string} target - the file to replace
220
+ * @param {'json' | 'jsonl'} format
221
+ * @returns {Promise<{ write: (document: any) => Promise<void>,
222
+ * commit: () => Promise<{ bytes: number, documents: number }>,
223
+ * abort: () => Promise<void>, temporary: string }>}
224
+ */
225
+ export async function openAtomicTarget(target, format) {
226
+ const directory = path.dirname(path.resolve(target));
227
+ const temporary = path.join(directory,
228
+ `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
229
+ const handle = await fsp.open(temporary, 'wx');
230
+ let documents = 0;
231
+ let bytes = 0;
232
+ let settled = false;
233
+
234
+ const put = async (text) => {
235
+ await handle.write(text);
236
+ bytes += Buffer.byteLength(text);
237
+ };
238
+ const discard = async () => {
239
+ try { await handle.close(); }
240
+ catch { /* A failed close must not prevent temporary-file removal. */ }
241
+ await fsp.rm(temporary, { force: true });
242
+ };
243
+
244
+ return {
245
+ temporary,
246
+ write: async (document) => {
247
+ const text = JSON.stringify(document);
248
+ if (format === 'jsonl') { documents++; return put(`${text}\n`); }
249
+ return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
250
+ },
251
+ commit: async () => {
252
+ if (settled) throw refuse('this target was already settled');
253
+ settled = true;
254
+ try {
255
+ if (format === 'json') await put(documents === 0 ? '[]\n' : '\n]\n');
256
+ // Publish only after the temporary has been durably flushed.
257
+ await handle.sync();
258
+ await handle.close();
259
+ await fsp.rename(temporary, target);
260
+ return { bytes, documents };
261
+ }
262
+ catch (error) {
263
+ try { await discard(); }
264
+ catch (cleanupError) { error.cleanupError = cleanupError; }
265
+ throw error;
266
+ }
267
+ },
268
+ abort: async () => {
269
+ if (settled) return;
270
+ settled = true;
271
+ await discard();
272
+ },
273
+ };
274
+ }
275
+
276
+ /**
277
+ * A sink that writes to an open stream (standard output) and can never
278
+ * be taken back — `abort` is honest that what left has left.
279
+ * @param {{ write: (chunk: string, callback: (error?: any) => void) => any }} stream
280
+ * @param {'json' | 'jsonl'} format
281
+ */
282
+ export function openStreamTarget(stream, format) {
283
+ let documents = 0;
284
+ const put = (text) => new Promise((resolve, reject) => {
285
+ stream.write(text, (error) => (error ? reject(error) : resolve(undefined)));
286
+ });
287
+ return {
288
+ temporary: null,
289
+ write: async (document) => {
290
+ const text = JSON.stringify(document);
291
+ if (format === 'jsonl') { documents++; return put(`${text}\n`); }
292
+ return put(documents++ === 0 ? `[\n${text}` : `,\n${text}`);
293
+ },
294
+ commit: async () => {
295
+ if (format === 'json') await put(documents === 0 ? '[]\n' : '\n]\n');
296
+ return { bytes: 0, documents };
297
+ },
298
+ abort: async () => undefined,
299
+ };
300
+ }
301
+
302
+ /** A sink that validates everything and writes nothing (`--check`). */
303
+ export function openNullTarget() {
304
+ let documents = 0;
305
+ return {
306
+ temporary: null,
307
+ write: async () => { documents++; },
308
+ commit: async () => ({ bytes: 0, documents }),
309
+ abort: async () => undefined,
310
+ };
311
+ }