@monlite/core 0.3.0 → 0.6.0
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/README.md +153 -9
- package/dist/index.cjs +914 -138
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +260 -11
- package/dist/index.d.ts +260 -11
- package/dist/index.js +906 -139
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -22,23 +22,73 @@ function isObjectId(value) {
|
|
|
22
22
|
|
|
23
23
|
// src/errors.ts
|
|
24
24
|
var MonliteError = class extends Error {
|
|
25
|
-
constructor(message) {
|
|
25
|
+
constructor(message, options) {
|
|
26
26
|
super(message);
|
|
27
27
|
this.name = "MonliteError";
|
|
28
|
+
if (options?.cause !== void 0) this.cause = options.cause;
|
|
28
29
|
}
|
|
29
30
|
};
|
|
30
31
|
var MonliteQueryError = class extends MonliteError {
|
|
31
|
-
constructor(message) {
|
|
32
|
-
super(message);
|
|
32
|
+
constructor(message, options) {
|
|
33
|
+
super(message, options);
|
|
33
34
|
this.name = "MonliteQueryError";
|
|
34
35
|
}
|
|
35
36
|
};
|
|
37
|
+
var MonliteConstraintError = class extends MonliteError {
|
|
38
|
+
collection;
|
|
39
|
+
constructor(message, options) {
|
|
40
|
+
super(message, options);
|
|
41
|
+
this.name = "MonliteConstraintError";
|
|
42
|
+
this.collection = options?.collection;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var MonliteUniqueConstraintError = class extends MonliteConstraintError {
|
|
46
|
+
constructor(message, options) {
|
|
47
|
+
super(message, options);
|
|
48
|
+
this.name = "MonliteUniqueConstraintError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var MonliteNotNullError = class extends MonliteConstraintError {
|
|
52
|
+
constructor(message, options) {
|
|
53
|
+
super(message, options);
|
|
54
|
+
this.name = "MonliteNotNullError";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var MonliteForeignKeyError = class extends MonliteConstraintError {
|
|
58
|
+
constructor(message, options) {
|
|
59
|
+
super(message, options);
|
|
60
|
+
this.name = "MonliteForeignKeyError";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
function normalizeDriverError(err, collection) {
|
|
64
|
+
if (err instanceof MonliteError) return err;
|
|
65
|
+
const code = err?.code ? String(err.code) : "";
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
const blob = `${code} ${message}`;
|
|
68
|
+
const opts = { cause: err, collection };
|
|
69
|
+
if (/UNIQUE|PRIMARY KEY|constraint failed: .*\.(?:_id)\b/i.test(blob)) {
|
|
70
|
+
return new MonliteUniqueConstraintError(message, opts);
|
|
71
|
+
}
|
|
72
|
+
if (/NOT ?NULL/i.test(blob)) {
|
|
73
|
+
return new MonliteNotNullError(message, opts);
|
|
74
|
+
}
|
|
75
|
+
if (/FOREIGN ?KEY/i.test(blob)) {
|
|
76
|
+
return new MonliteForeignKeyError(message, opts);
|
|
77
|
+
}
|
|
78
|
+
if (/CONSTRAINT/i.test(blob)) {
|
|
79
|
+
return new MonliteConstraintError(message, opts);
|
|
80
|
+
}
|
|
81
|
+
return new MonliteError(message, { cause: err });
|
|
82
|
+
}
|
|
36
83
|
|
|
37
84
|
// src/query/sql.ts
|
|
38
85
|
var RESERVED_FIELDS = /* @__PURE__ */ new Set(["_id", "created_at", "updated_at"]);
|
|
39
86
|
function isReserved(field) {
|
|
40
87
|
return RESERVED_FIELDS.has(field);
|
|
41
88
|
}
|
|
89
|
+
function isColumn(field, columns) {
|
|
90
|
+
return isReserved(field) || (columns?.has(field) ?? false);
|
|
91
|
+
}
|
|
42
92
|
function jsonPath(field) {
|
|
43
93
|
let path = "$";
|
|
44
94
|
for (const seg of field.split(".")) {
|
|
@@ -55,10 +105,13 @@ function jsonPath(field) {
|
|
|
55
105
|
function pathLiteral(field) {
|
|
56
106
|
return "'" + jsonPath(field).replace(/'/g, "''") + "'";
|
|
57
107
|
}
|
|
58
|
-
function fieldExpr(field) {
|
|
59
|
-
if (
|
|
108
|
+
function fieldExpr(field, columns) {
|
|
109
|
+
if (isColumn(field, columns)) return quoteIdent(field);
|
|
60
110
|
return `json_extract(data, ${pathLiteral(field)})`;
|
|
61
111
|
}
|
|
112
|
+
function quoteIdent(name) {
|
|
113
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
114
|
+
}
|
|
62
115
|
function bindable(value) {
|
|
63
116
|
if (value === void 0 || value === null) return null;
|
|
64
117
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
@@ -103,16 +156,17 @@ function isFilterObject(v) {
|
|
|
103
156
|
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date) && !Buffer.isBuffer(v) && (v.constructor === Object || v.constructor === void 0);
|
|
104
157
|
}
|
|
105
158
|
function translateField(field, condition, ctx) {
|
|
106
|
-
if (ctx.onPath && !
|
|
107
|
-
const expr = fieldExpr(field);
|
|
159
|
+
if (ctx.onPath && !isColumn(field, ctx.columns)) ctx.onPath(field);
|
|
160
|
+
const expr = fieldExpr(field, ctx.columns);
|
|
108
161
|
if (!isFilterObject(condition)) {
|
|
109
162
|
return eqExpr(expr, condition, ctx);
|
|
110
163
|
}
|
|
111
164
|
const filter = condition;
|
|
165
|
+
const ci = filter.mode === "insensitive";
|
|
112
166
|
const clauses = [];
|
|
113
167
|
for (const op of Object.keys(filter)) {
|
|
114
168
|
const v = filter[op];
|
|
115
|
-
if (v === void 0) continue;
|
|
169
|
+
if (v === void 0 || op === "mode") continue;
|
|
116
170
|
switch (op) {
|
|
117
171
|
case "equals":
|
|
118
172
|
clauses.push(eqExpr(expr, v, ctx));
|
|
@@ -139,22 +193,26 @@ function translateField(field, condition, ctx) {
|
|
|
139
193
|
clauses.push(inExpr(expr, v, ctx, true));
|
|
140
194
|
break;
|
|
141
195
|
case "contains":
|
|
142
|
-
clauses.push(containsExpr(field, expr, v, ctx));
|
|
196
|
+
clauses.push(containsExpr(field, expr, v, ctx, ci));
|
|
143
197
|
break;
|
|
144
198
|
case "startsWith":
|
|
145
199
|
ctx.params.push(bindable(v));
|
|
146
|
-
clauses.push(
|
|
200
|
+
clauses.push(
|
|
201
|
+
ci ? `instr(lower(${expr}), lower(?)) = 1` : `instr(${expr}, ?) = 1`
|
|
202
|
+
);
|
|
147
203
|
break;
|
|
148
204
|
case "endsWith":
|
|
149
205
|
ctx.params.push(bindable(v));
|
|
150
206
|
ctx.params.push(bindable(v));
|
|
151
|
-
clauses.push(
|
|
207
|
+
clauses.push(
|
|
208
|
+
ci ? `substr(lower(${expr}), -length(?)) = lower(?)` : `substr(${expr}, -length(?)) = ?`
|
|
209
|
+
);
|
|
152
210
|
break;
|
|
153
211
|
case "has":
|
|
154
212
|
clauses.push(hasExpr(field, expr, v, ctx));
|
|
155
213
|
break;
|
|
156
214
|
case "exists":
|
|
157
|
-
clauses.push(existsExpr(field, expr, !!v));
|
|
215
|
+
clauses.push(existsExpr(field, expr, !!v, ctx.columns));
|
|
158
216
|
break;
|
|
159
217
|
default:
|
|
160
218
|
throw new MonliteQueryError(
|
|
@@ -192,23 +250,25 @@ function inExpr(expr, arr, ctx, negate) {
|
|
|
192
250
|
}).join(", ");
|
|
193
251
|
return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
|
|
194
252
|
}
|
|
195
|
-
function containsExpr(field, expr, v, ctx) {
|
|
196
|
-
|
|
253
|
+
function containsExpr(field, expr, v, ctx, ci) {
|
|
254
|
+
const sub = ci ? `instr(lower(${expr}), lower(?)) > 0` : `instr(${expr}, ?) > 0`;
|
|
255
|
+
if (isColumn(field, ctx.columns)) {
|
|
197
256
|
ctx.params.push(bindable(v));
|
|
198
|
-
return
|
|
257
|
+
return sub;
|
|
199
258
|
}
|
|
200
259
|
const path = pathLiteral(field);
|
|
260
|
+
const member = ci ? `lower(value) = lower(?)` : `value = ?`;
|
|
201
261
|
ctx.params.push(bindable(v));
|
|
202
262
|
ctx.params.push(bindable(v));
|
|
203
|
-
return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE
|
|
263
|
+
return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE ${member}) ELSE ${sub} END)`;
|
|
204
264
|
}
|
|
205
265
|
function hasExpr(field, expr, v, ctx) {
|
|
206
266
|
ctx.params.push(bindable(v));
|
|
207
|
-
if (
|
|
267
|
+
if (isColumn(field, ctx.columns)) return `${expr} = ?`;
|
|
208
268
|
return `EXISTS (SELECT 1 FROM json_each(data, ${pathLiteral(field)}) WHERE value = ?)`;
|
|
209
269
|
}
|
|
210
|
-
function existsExpr(field, expr, want) {
|
|
211
|
-
if (
|
|
270
|
+
function existsExpr(field, expr, want, columns) {
|
|
271
|
+
if (isColumn(field, columns)) {
|
|
212
272
|
return want ? `${expr} IS NOT NULL` : `${expr} IS NULL`;
|
|
213
273
|
}
|
|
214
274
|
const path = pathLiteral(field);
|
|
@@ -216,7 +276,7 @@ function existsExpr(field, expr, want) {
|
|
|
216
276
|
}
|
|
217
277
|
|
|
218
278
|
// src/query/order.ts
|
|
219
|
-
function buildOrderBy(orderBy, onPath) {
|
|
279
|
+
function buildOrderBy(orderBy, onPath, columns) {
|
|
220
280
|
if (!orderBy) return "";
|
|
221
281
|
const list = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
222
282
|
const parts = [];
|
|
@@ -224,25 +284,35 @@ function buildOrderBy(orderBy, onPath) {
|
|
|
224
284
|
for (const field of Object.keys(obj)) {
|
|
225
285
|
const dir = obj[field];
|
|
226
286
|
if (dir === void 0) continue;
|
|
227
|
-
if (onPath && !
|
|
287
|
+
if (onPath && !isColumn(field, columns)) onPath(field);
|
|
228
288
|
const d = String(dir).toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
229
|
-
parts.push(`${fieldExpr(field)} ${d}`);
|
|
289
|
+
parts.push(`${fieldExpr(field, columns)} ${d}`);
|
|
230
290
|
}
|
|
231
291
|
}
|
|
232
292
|
return parts.length ? "ORDER BY " + parts.join(", ") : "";
|
|
233
293
|
}
|
|
234
294
|
|
|
235
295
|
// src/query/path.ts
|
|
296
|
+
var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
297
|
+
function safeSegments(path) {
|
|
298
|
+
const segs = path.split(".");
|
|
299
|
+
for (const seg of segs) {
|
|
300
|
+
if (FORBIDDEN.has(seg)) {
|
|
301
|
+
throw new MonliteQueryError(`Illegal path segment "${seg}" in "${path}"`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return segs;
|
|
305
|
+
}
|
|
236
306
|
function getPath(obj, path) {
|
|
237
307
|
let cur = obj;
|
|
238
|
-
for (const seg of path
|
|
308
|
+
for (const seg of safeSegments(path)) {
|
|
239
309
|
if (cur == null) return void 0;
|
|
240
310
|
cur = cur[seg];
|
|
241
311
|
}
|
|
242
312
|
return cur;
|
|
243
313
|
}
|
|
244
314
|
function setPath(obj, path, value) {
|
|
245
|
-
const segs = path
|
|
315
|
+
const segs = safeSegments(path);
|
|
246
316
|
let cur = obj;
|
|
247
317
|
for (let i = 0; i < segs.length - 1; i++) {
|
|
248
318
|
const seg = segs[i];
|
|
@@ -252,7 +322,7 @@ function setPath(obj, path, value) {
|
|
|
252
322
|
cur[segs[segs.length - 1]] = value;
|
|
253
323
|
}
|
|
254
324
|
function unsetPath(obj, path) {
|
|
255
|
-
const segs = path
|
|
325
|
+
const segs = safeSegments(path);
|
|
256
326
|
let cur = obj;
|
|
257
327
|
for (let i = 0; i < segs.length - 1; i++) {
|
|
258
328
|
const seg = segs[i];
|
|
@@ -305,8 +375,13 @@ function applyUpdate(doc, data) {
|
|
|
305
375
|
}
|
|
306
376
|
if (ops.$inc) {
|
|
307
377
|
for (const [path, by] of Object.entries(ops.$inc)) {
|
|
378
|
+
if (typeof by !== "number" || !Number.isFinite(by)) {
|
|
379
|
+
throw new MonliteQueryError(
|
|
380
|
+
`$inc on "${path}" requires a finite number, got ${JSON.stringify(by)}`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
308
383
|
const cur = getPath(next, path);
|
|
309
|
-
setPath(next, path, (typeof cur === "number" ? cur : 0) +
|
|
384
|
+
setPath(next, path, (typeof cur === "number" ? cur : 0) + by);
|
|
310
385
|
}
|
|
311
386
|
}
|
|
312
387
|
if (ops.$push) {
|
|
@@ -347,7 +422,7 @@ var SQL_FN = {
|
|
|
347
422
|
_min: "MIN",
|
|
348
423
|
_max: "MAX"
|
|
349
424
|
};
|
|
350
|
-
function buildAccumulators(args, onPath) {
|
|
425
|
+
function buildAccumulators(args, onPath, columns) {
|
|
351
426
|
const selects = [];
|
|
352
427
|
const cols = [];
|
|
353
428
|
let i = 0;
|
|
@@ -356,9 +431,9 @@ function buildAccumulators(args, onPath) {
|
|
|
356
431
|
if (!selection) continue;
|
|
357
432
|
for (const field of Object.keys(selection)) {
|
|
358
433
|
if (!selection[field]) continue;
|
|
359
|
-
onPath(field);
|
|
434
|
+
if (!isColumn(field, columns)) onPath(field);
|
|
360
435
|
const alias = `agg_${kind.slice(1)}_${i++}`;
|
|
361
|
-
selects.push(`${SQL_FN[kind]}(${fieldExpr(field)}) AS ${alias}`);
|
|
436
|
+
selects.push(`${SQL_FN[kind]}(${fieldExpr(field, columns)}) AS ${alias}`);
|
|
362
437
|
cols.push({ alias, kind, field });
|
|
363
438
|
}
|
|
364
439
|
}
|
|
@@ -366,8 +441,12 @@ function buildAccumulators(args, onPath) {
|
|
|
366
441
|
}
|
|
367
442
|
function aggregate(ctx, args) {
|
|
368
443
|
const params = [];
|
|
369
|
-
const where = buildWhere(args.where, {
|
|
370
|
-
|
|
444
|
+
const where = buildWhere(args.where, {
|
|
445
|
+
params,
|
|
446
|
+
onPath: ctx.onPath,
|
|
447
|
+
columns: ctx.columns
|
|
448
|
+
});
|
|
449
|
+
const { selects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
|
|
371
450
|
const allSelects = [`COUNT(*) AS agg_count`, ...selects];
|
|
372
451
|
const sql = `SELECT ${allSelects.join(", ")} FROM "${ctx.table}" WHERE ${where}`;
|
|
373
452
|
const row = ctx.db.prepare(sql).get(...params) ?? {};
|
|
@@ -403,7 +482,7 @@ function comparisonSql(expr, cmp2, params) {
|
|
|
403
482
|
}
|
|
404
483
|
return out;
|
|
405
484
|
}
|
|
406
|
-
function buildHaving(having, params) {
|
|
485
|
+
function buildHaving(having, params, columns) {
|
|
407
486
|
const parts = [];
|
|
408
487
|
if (having._count) {
|
|
409
488
|
parts.push(...comparisonSql("COUNT(*)", having._count, params));
|
|
@@ -412,31 +491,40 @@ function buildHaving(having, params) {
|
|
|
412
491
|
const selection = having[kind];
|
|
413
492
|
if (!selection) continue;
|
|
414
493
|
for (const field of Object.keys(selection)) {
|
|
415
|
-
parts.push(
|
|
494
|
+
parts.push(
|
|
495
|
+
...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
|
|
496
|
+
);
|
|
416
497
|
}
|
|
417
498
|
}
|
|
418
499
|
return parts.join(" AND ");
|
|
419
500
|
}
|
|
420
501
|
function groupBy(ctx, args) {
|
|
421
502
|
if (!Array.isArray(args.by) || args.by.length === 0) {
|
|
422
|
-
throw new
|
|
503
|
+
throw new MonliteQueryError("groupBy requires a non-empty `by` array");
|
|
423
504
|
}
|
|
424
505
|
const params = [];
|
|
425
|
-
const where = buildWhere(args.where, {
|
|
506
|
+
const where = buildWhere(args.where, {
|
|
507
|
+
params,
|
|
508
|
+
onPath: ctx.onPath,
|
|
509
|
+
columns: ctx.columns
|
|
510
|
+
});
|
|
426
511
|
const groupExprs = [];
|
|
512
|
+
const groupCols = [];
|
|
427
513
|
const selects = [];
|
|
428
|
-
|
|
429
|
-
ctx.onPath(field);
|
|
430
|
-
const expr = fieldExpr(field);
|
|
514
|
+
args.by.forEach((field, gi) => {
|
|
515
|
+
if (!isColumn(field, ctx.columns)) ctx.onPath(field);
|
|
516
|
+
const expr = fieldExpr(field, ctx.columns);
|
|
517
|
+
const alias = `grp_${gi}`;
|
|
431
518
|
groupExprs.push(expr);
|
|
432
|
-
selects.push(`${expr} AS
|
|
433
|
-
|
|
519
|
+
selects.push(`${expr} AS ${alias}`);
|
|
520
|
+
groupCols.push({ alias, field });
|
|
521
|
+
});
|
|
434
522
|
selects.push(`COUNT(*) AS agg_count`);
|
|
435
|
-
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath);
|
|
523
|
+
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
|
|
436
524
|
selects.push(...accSelects);
|
|
437
525
|
let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
|
|
438
526
|
if (args.having) {
|
|
439
|
-
const havingSql = buildHaving(args.having, params);
|
|
527
|
+
const havingSql = buildHaving(args.having, params, ctx.columns);
|
|
440
528
|
if (havingSql) sql += ` HAVING ${havingSql}`;
|
|
441
529
|
}
|
|
442
530
|
if (args.orderBy) {
|
|
@@ -444,7 +532,7 @@ function groupBy(ctx, args) {
|
|
|
444
532
|
for (const key of Object.keys(args.orderBy)) {
|
|
445
533
|
const dir = String(args.orderBy[key]).toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
446
534
|
if (key === "_count") parts.push(`agg_count ${dir}`);
|
|
447
|
-
else parts.push(`${fieldExpr(key)} ${dir}`);
|
|
535
|
+
else parts.push(`${fieldExpr(key, ctx.columns)} ${dir}`);
|
|
448
536
|
}
|
|
449
537
|
if (parts.length) sql += ` ORDER BY ${parts.join(", ")}`;
|
|
450
538
|
}
|
|
@@ -459,7 +547,7 @@ function groupBy(ctx, args) {
|
|
|
459
547
|
const rows = ctx.db.prepare(sql).all(...params);
|
|
460
548
|
return rows.map((row) => {
|
|
461
549
|
const out = {};
|
|
462
|
-
for (const field of
|
|
550
|
+
for (const { alias, field } of groupCols) out[field] = row[alias];
|
|
463
551
|
if (args._count) out._count = row.agg_count;
|
|
464
552
|
for (const col of cols) {
|
|
465
553
|
(out[col.kind] ??= {})[col.field] = row[col.alias] ?? null;
|
|
@@ -469,82 +557,256 @@ function groupBy(ctx, args) {
|
|
|
469
557
|
}
|
|
470
558
|
|
|
471
559
|
// src/collection.ts
|
|
472
|
-
var SELECT_COLS = `_id, data, created_at, updated_at`;
|
|
473
560
|
function stripSystem(obj) {
|
|
474
561
|
const { _id, created_at, updated_at, ...rest } = obj;
|
|
475
562
|
return rest;
|
|
476
563
|
}
|
|
564
|
+
var NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
565
|
+
function sqliteType(type) {
|
|
566
|
+
return type === "JSON" ? "TEXT" : type;
|
|
567
|
+
}
|
|
568
|
+
function formatDefault(value) {
|
|
569
|
+
if (value === null) return "NULL";
|
|
570
|
+
if (typeof value === "number") return String(value);
|
|
571
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
572
|
+
}
|
|
477
573
|
var Collection = class {
|
|
478
|
-
constructor(mon, name) {
|
|
574
|
+
constructor(mon, name, options = {}) {
|
|
479
575
|
this.mon = mon;
|
|
480
576
|
this.name = name;
|
|
577
|
+
this.mode = options.schema ? "structured" : "document";
|
|
578
|
+
if (options.schema) {
|
|
579
|
+
for (const [field, def] of Object.entries(options.schema)) {
|
|
580
|
+
if (!NAME_RE.test(field)) {
|
|
581
|
+
throw new MonliteError(`Invalid column name "${field}"`);
|
|
582
|
+
}
|
|
583
|
+
if (RESERVED_FIELDS.has(field) || field === "data") {
|
|
584
|
+
throw new MonliteError(
|
|
585
|
+
`Column "${field}" is reserved by monlite and cannot be declared`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
const normalized = typeof def === "string" ? { type: def } : def;
|
|
589
|
+
if (normalized.references && !/^[A-Za-z_][A-Za-z0-9_]*(\([A-Za-z_][A-Za-z0-9_]*\))?$/.test(
|
|
590
|
+
normalized.references
|
|
591
|
+
)) {
|
|
592
|
+
throw new MonliteError(
|
|
593
|
+
`Invalid references "${normalized.references}" on column "${field}"`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
this.columnDefs[field] = normalized;
|
|
597
|
+
this.columnOrder.push(field);
|
|
598
|
+
this.columns.add(field);
|
|
599
|
+
if (normalized.type === "JSON") this.jsonColumns.add(field);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
481
602
|
}
|
|
482
603
|
mon;
|
|
483
604
|
name;
|
|
605
|
+
mode;
|
|
484
606
|
initialized = false;
|
|
607
|
+
columnDefs = {};
|
|
608
|
+
columnOrder = [];
|
|
609
|
+
/** Declared native columns (empty in document mode). */
|
|
610
|
+
columns = /* @__PURE__ */ new Set();
|
|
611
|
+
jsonColumns = /* @__PURE__ */ new Set();
|
|
612
|
+
insertSqlCache;
|
|
485
613
|
trackPath = (path) => this.mon.autoIndexer.track(this.name, path);
|
|
486
614
|
get db() {
|
|
487
615
|
return this.mon.driver;
|
|
488
616
|
}
|
|
617
|
+
/** Run a DB operation, normalizing driver errors into typed MonliteErrors. */
|
|
618
|
+
guard(fn) {
|
|
619
|
+
try {
|
|
620
|
+
return fn();
|
|
621
|
+
} catch (err) {
|
|
622
|
+
throw normalizeDriverError(err, this.name);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** Native column names declared for this collection (structured mode). */
|
|
626
|
+
get columnNames() {
|
|
627
|
+
return [...this.columnOrder];
|
|
628
|
+
}
|
|
489
629
|
ensureTable() {
|
|
490
630
|
if (this.initialized) return;
|
|
491
|
-
this.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
631
|
+
if (this.mode === "document") {
|
|
632
|
+
this.db.exec(
|
|
633
|
+
`CREATE TABLE IF NOT EXISTS "${this.name}" (
|
|
634
|
+
_id TEXT PRIMARY KEY,
|
|
635
|
+
data TEXT NOT NULL,
|
|
636
|
+
created_at INTEGER NOT NULL,
|
|
637
|
+
updated_at INTEGER NOT NULL
|
|
638
|
+
)`
|
|
639
|
+
);
|
|
640
|
+
} else {
|
|
641
|
+
const lines = [
|
|
642
|
+
`_id TEXT PRIMARY KEY`,
|
|
643
|
+
`created_at INTEGER NOT NULL`,
|
|
644
|
+
`updated_at INTEGER NOT NULL`,
|
|
645
|
+
`data TEXT NOT NULL DEFAULT '{}'`
|
|
646
|
+
];
|
|
647
|
+
for (const field of this.columnOrder) {
|
|
648
|
+
const def = this.columnDefs[field];
|
|
649
|
+
let line = `"${field}" ${sqliteType(def.type)}`;
|
|
650
|
+
if (def.notNull) line += " NOT NULL";
|
|
651
|
+
if (def.unique) line += " UNIQUE";
|
|
652
|
+
if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
|
|
653
|
+
if (def.references) line += ` REFERENCES ${def.references}`;
|
|
654
|
+
lines.push(line);
|
|
655
|
+
}
|
|
656
|
+
this.db.exec(
|
|
657
|
+
`CREATE TABLE IF NOT EXISTS "${this.name}" (
|
|
658
|
+
${lines.join(",\n ")}
|
|
659
|
+
)`
|
|
660
|
+
);
|
|
661
|
+
for (const field of this.columnOrder) {
|
|
662
|
+
if (this.columnDefs[field].index) {
|
|
663
|
+
this.db.exec(
|
|
664
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.name}_${field}" ON "${this.name}"("${field}")`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
499
669
|
this.initialized = true;
|
|
500
670
|
}
|
|
671
|
+
/* --------------------------- row <-> doc -------------------------- */
|
|
501
672
|
rowToDoc(row) {
|
|
502
|
-
const doc = JSON.parse(row.data);
|
|
673
|
+
const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
|
|
674
|
+
if (this.mode === "structured") {
|
|
675
|
+
for (const field of this.columnOrder) {
|
|
676
|
+
const value = row[field];
|
|
677
|
+
if (value === void 0) continue;
|
|
678
|
+
if (value === null) {
|
|
679
|
+
doc[field] = null;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
503
685
|
doc._id = row._id;
|
|
504
686
|
doc.created_at = row.created_at;
|
|
505
687
|
doc.updated_at = row.updated_at;
|
|
506
688
|
return doc;
|
|
507
689
|
}
|
|
508
|
-
|
|
690
|
+
encodeColumn(field, value) {
|
|
691
|
+
if (this.jsonColumns.has(field)) {
|
|
692
|
+
return value === void 0 ? null : JSON.stringify(value);
|
|
693
|
+
}
|
|
694
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date) && !Buffer.isBuffer(value)) {
|
|
695
|
+
throw new MonliteQueryError(
|
|
696
|
+
`Column "${field}" cannot store an object/array. Declare it as { type: "JSON" } to store structured values.`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
return bindable(value);
|
|
700
|
+
}
|
|
701
|
+
insertColumns() {
|
|
702
|
+
return this.mode === "document" ? ["_id", "data", "created_at", "updated_at"] : ["_id", "created_at", "updated_at", "data", ...this.columnOrder];
|
|
703
|
+
}
|
|
704
|
+
insertSql() {
|
|
705
|
+
if (this.insertSqlCache) return this.insertSqlCache;
|
|
706
|
+
const cols = this.insertColumns();
|
|
707
|
+
const list = cols.map((c) => `"${c}"`).join(", ");
|
|
708
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
709
|
+
return this.insertSqlCache = `INSERT INTO "${this.name}" (${list}) VALUES (${placeholders})`;
|
|
710
|
+
}
|
|
711
|
+
/** Split an input document into a row aligned with `insertColumns()`. */
|
|
712
|
+
buildInsert(input) {
|
|
509
713
|
const now = Date.now();
|
|
510
714
|
const id = input._id != null ? String(input._id) : objectId();
|
|
511
715
|
const doc = stripSystem(input);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
716
|
+
const returned = { ...doc, _id: id, created_at: now, updated_at: now };
|
|
717
|
+
if (this.mode === "document") {
|
|
718
|
+
return {
|
|
719
|
+
_id: id,
|
|
720
|
+
created_at: now,
|
|
721
|
+
updated_at: now,
|
|
722
|
+
values: [id, JSON.stringify(doc), now, now],
|
|
723
|
+
returned
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const overflow = {};
|
|
727
|
+
const colValues = {};
|
|
728
|
+
for (const [k, v] of Object.entries(doc)) {
|
|
729
|
+
if (this.columns.has(k)) colValues[k] = v;
|
|
730
|
+
else overflow[k] = v;
|
|
731
|
+
}
|
|
732
|
+
const values = [
|
|
733
|
+
id,
|
|
734
|
+
now,
|
|
735
|
+
now,
|
|
736
|
+
JSON.stringify(overflow),
|
|
737
|
+
...this.columnOrder.map(
|
|
738
|
+
(c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
|
|
739
|
+
)
|
|
740
|
+
];
|
|
741
|
+
return { _id: id, created_at: now, updated_at: now, values, returned };
|
|
742
|
+
}
|
|
743
|
+
/** Build the `SET` clause + values to persist an updated document. */
|
|
744
|
+
buildUpdateSet(updatedDoc, now) {
|
|
745
|
+
if (this.mode === "document") {
|
|
746
|
+
return {
|
|
747
|
+
setSql: `data = ?, updated_at = ?`,
|
|
748
|
+
values: [JSON.stringify(updatedDoc), now]
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const overflow = {};
|
|
752
|
+
const colValues = {};
|
|
753
|
+
for (const [k, v] of Object.entries(updatedDoc)) {
|
|
754
|
+
if (this.columns.has(k)) colValues[k] = v;
|
|
755
|
+
else overflow[k] = v;
|
|
756
|
+
}
|
|
757
|
+
const setParts = this.columnOrder.map((c) => `"${c}" = ?`);
|
|
758
|
+
setParts.push(`data = ?`, `updated_at = ?`);
|
|
759
|
+
const values = [
|
|
760
|
+
...this.columnOrder.map(
|
|
761
|
+
(c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
|
|
762
|
+
),
|
|
763
|
+
JSON.stringify(overflow),
|
|
764
|
+
now
|
|
765
|
+
];
|
|
766
|
+
return { setSql: setParts.join(", "), values };
|
|
767
|
+
}
|
|
768
|
+
/** Sync store, but only for document collections (structured sync is future work). */
|
|
769
|
+
get recorder() {
|
|
770
|
+
return this.mode === "document" ? this.mon.$sync : void 0;
|
|
518
771
|
}
|
|
519
772
|
/* ----------------------------- create ----------------------------- */
|
|
520
773
|
async create(args) {
|
|
521
774
|
this.ensureTable();
|
|
522
|
-
const row = this.
|
|
523
|
-
this.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
775
|
+
const row = this.buildInsert(args.data);
|
|
776
|
+
const recorder = this.recorder;
|
|
777
|
+
const write = () => {
|
|
778
|
+
this.db.prepare(this.insertSql()).run(...row.values);
|
|
779
|
+
recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
|
|
780
|
+
};
|
|
781
|
+
this.guard(() => recorder ? this.db.transaction(write) : write());
|
|
782
|
+
return row.returned;
|
|
527
783
|
}
|
|
528
784
|
async createMany(args) {
|
|
529
785
|
this.ensureTable();
|
|
530
|
-
const stmt = this.db.prepare(
|
|
531
|
-
|
|
786
|
+
const stmt = this.db.prepare(this.insertSql());
|
|
787
|
+
const recorder = this.recorder;
|
|
788
|
+
this.guard(
|
|
789
|
+
() => this.db.transaction(() => {
|
|
790
|
+
for (const item of args.data) {
|
|
791
|
+
const row = this.buildInsert(item);
|
|
792
|
+
stmt.run(...row.values);
|
|
793
|
+
recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
|
|
794
|
+
}
|
|
795
|
+
})
|
|
532
796
|
);
|
|
533
|
-
this.db.transaction(() => {
|
|
534
|
-
for (const item of args.data) {
|
|
535
|
-
const row = this.prepareInsert(item);
|
|
536
|
-
stmt.run(row._id, row.data, row.created_at, row.updated_at);
|
|
537
|
-
}
|
|
538
|
-
});
|
|
539
797
|
return { count: args.data.length };
|
|
540
798
|
}
|
|
541
799
|
/* ------------------------------ read ------------------------------ */
|
|
542
800
|
async findMany(args = {}) {
|
|
543
801
|
this.ensureTable();
|
|
544
802
|
const params = [];
|
|
545
|
-
const where = buildWhere(args.where, {
|
|
546
|
-
|
|
547
|
-
|
|
803
|
+
const where = buildWhere(args.where, {
|
|
804
|
+
params,
|
|
805
|
+
onPath: this.trackPath,
|
|
806
|
+
columns: this.columns
|
|
807
|
+
});
|
|
808
|
+
let sql = `SELECT * FROM "${this.name}" WHERE ${where}`;
|
|
809
|
+
const order = buildOrderBy(args.orderBy, this.trackPath, this.columns);
|
|
548
810
|
if (order) sql += " " + order;
|
|
549
811
|
if (args.take != null) {
|
|
550
812
|
sql += " LIMIT ?";
|
|
@@ -555,72 +817,108 @@ var Collection = class {
|
|
|
555
817
|
params.push(args.skip);
|
|
556
818
|
}
|
|
557
819
|
const rows = this.db.prepare(sql).all(...params);
|
|
558
|
-
return rows.map(
|
|
559
|
-
(r) => project(this.rowToDoc(r), args.select)
|
|
560
|
-
);
|
|
820
|
+
return rows.map((r) => project(this.rowToDoc(r), args.select));
|
|
561
821
|
}
|
|
562
822
|
async findFirst(args = {}) {
|
|
563
823
|
const rows = await this.findMany({ ...args, take: 1 });
|
|
564
824
|
return rows[0] ?? null;
|
|
565
825
|
}
|
|
826
|
+
/** Alias of {@link findFirst} for Prisma familiarity. */
|
|
827
|
+
async findUnique(args = {}) {
|
|
828
|
+
return this.findFirst(args);
|
|
829
|
+
}
|
|
830
|
+
/** Like {@link findFirst} but throws if no document matches. */
|
|
831
|
+
async findFirstOrThrow(args = {}) {
|
|
832
|
+
const doc = await this.findFirst(args);
|
|
833
|
+
if (!doc) throw new MonliteError(`No document found in "${this.name}"`);
|
|
834
|
+
return doc;
|
|
835
|
+
}
|
|
836
|
+
/** True if at least one document matches. */
|
|
837
|
+
async exists(where) {
|
|
838
|
+
this.ensureTable();
|
|
839
|
+
const params = [];
|
|
840
|
+
const clause = buildWhere(where, {
|
|
841
|
+
params,
|
|
842
|
+
onPath: this.trackPath,
|
|
843
|
+
columns: this.columns
|
|
844
|
+
});
|
|
845
|
+
const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
|
|
846
|
+
return row != null;
|
|
847
|
+
}
|
|
566
848
|
async findById(id) {
|
|
567
849
|
this.ensureTable();
|
|
568
|
-
const row = this.db.prepare(`SELECT
|
|
850
|
+
const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
|
|
569
851
|
return row ? this.rowToDoc(row) : null;
|
|
570
852
|
}
|
|
571
853
|
async count(args = {}) {
|
|
572
854
|
this.ensureTable();
|
|
573
855
|
const params = [];
|
|
574
|
-
const where = buildWhere(args.where, {
|
|
856
|
+
const where = buildWhere(args.where, {
|
|
857
|
+
params,
|
|
858
|
+
onPath: this.trackPath,
|
|
859
|
+
columns: this.columns
|
|
860
|
+
});
|
|
575
861
|
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM "${this.name}" WHERE ${where}`).get(...params);
|
|
576
862
|
return row.n;
|
|
577
863
|
}
|
|
578
864
|
/**
|
|
579
|
-
* Return the distinct values of a field
|
|
580
|
-
*
|
|
865
|
+
* Return the distinct values of a field. Array fields stored in JSON are
|
|
866
|
+
* unwound (each element counts as a value), matching MongoDB's `distinct`.
|
|
581
867
|
*/
|
|
582
868
|
async distinct(field, where) {
|
|
583
869
|
this.ensureTable();
|
|
584
870
|
const params = [];
|
|
585
|
-
const clause = buildWhere(where, {
|
|
871
|
+
const clause = buildWhere(where, {
|
|
872
|
+
params,
|
|
873
|
+
onPath: this.trackPath,
|
|
874
|
+
columns: this.columns
|
|
875
|
+
});
|
|
586
876
|
let sql;
|
|
587
|
-
if (
|
|
588
|
-
sql = `SELECT DISTINCT ${fieldExpr(field)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
|
|
877
|
+
if (isColumn(field, this.columns)) {
|
|
878
|
+
sql = `SELECT DISTINCT ${fieldExpr(field, this.columns)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
|
|
589
879
|
} else {
|
|
590
880
|
this.trackPath(field);
|
|
591
881
|
sql = `SELECT DISTINCT je.value AS v FROM "${this.name}" CROSS JOIN json_each("${this.name}".data, ${pathLiteral(field)}) je WHERE ${clause} ORDER BY v`;
|
|
592
882
|
}
|
|
593
|
-
|
|
594
|
-
|
|
883
|
+
return this.guard(() => {
|
|
884
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
885
|
+
return rows.map((r) => r.v);
|
|
886
|
+
});
|
|
595
887
|
}
|
|
596
888
|
/* ----------------------------- update ----------------------------- */
|
|
597
889
|
runUpdate(where, data, single) {
|
|
598
890
|
this.ensureTable();
|
|
599
891
|
const params = [];
|
|
600
|
-
const clause = buildWhere(where, {
|
|
601
|
-
|
|
892
|
+
const clause = buildWhere(where, {
|
|
893
|
+
params,
|
|
894
|
+
onPath: this.trackPath,
|
|
895
|
+
columns: this.columns
|
|
896
|
+
});
|
|
897
|
+
let selectSql = `SELECT * FROM "${this.name}" WHERE ${clause}`;
|
|
602
898
|
if (single) selectSql += " LIMIT 1";
|
|
603
899
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
604
900
|
if (!rows.length) return [];
|
|
605
901
|
const now = Date.now();
|
|
606
|
-
const
|
|
607
|
-
|
|
902
|
+
const recorder = this.recorder;
|
|
903
|
+
return this.guard(
|
|
904
|
+
() => this.db.transaction(() => {
|
|
905
|
+
const out = [];
|
|
906
|
+
for (const row of rows) {
|
|
907
|
+
const current = stripSystem(this.rowToDoc(row));
|
|
908
|
+
const updated = stripSystem(applyUpdate(current, data));
|
|
909
|
+
const { setSql, values } = this.buildUpdateSet(updated, now);
|
|
910
|
+
this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
|
|
911
|
+
recorder?.recordLocal(this.name, row._id, "upsert", now);
|
|
912
|
+
out.push({
|
|
913
|
+
...updated,
|
|
914
|
+
_id: row._id,
|
|
915
|
+
created_at: row.created_at,
|
|
916
|
+
updated_at: now
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
return out;
|
|
920
|
+
})
|
|
608
921
|
);
|
|
609
|
-
return this.db.transaction(() => {
|
|
610
|
-
const out = [];
|
|
611
|
-
for (const row of rows) {
|
|
612
|
-
const current = JSON.parse(row.data);
|
|
613
|
-
const updated = stripSystem(applyUpdate(current, data));
|
|
614
|
-
stmt.run(JSON.stringify(updated), now, row._id);
|
|
615
|
-
out.push({
|
|
616
|
-
...updated,
|
|
617
|
-
_id: row._id,
|
|
618
|
-
created_at: row.created_at,
|
|
619
|
-
updated_at: now
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
return out;
|
|
623
|
-
});
|
|
624
922
|
}
|
|
625
923
|
async update(args) {
|
|
626
924
|
return this.runUpdate(args.where, args.data, true)[0] ?? null;
|
|
@@ -630,29 +928,61 @@ var Collection = class {
|
|
|
630
928
|
}
|
|
631
929
|
async upsert(args) {
|
|
632
930
|
this.ensureTable();
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
931
|
+
return this.guard(
|
|
932
|
+
() => this.db.transaction(() => {
|
|
933
|
+
const params = [];
|
|
934
|
+
const clause = buildWhere(args.where, {
|
|
935
|
+
params,
|
|
936
|
+
onPath: this.trackPath,
|
|
937
|
+
columns: this.columns
|
|
938
|
+
});
|
|
939
|
+
const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
const recorder = this.recorder;
|
|
942
|
+
if (row) {
|
|
943
|
+
const current = stripSystem(this.rowToDoc(row));
|
|
944
|
+
const updated = stripSystem(applyUpdate(current, args.update));
|
|
945
|
+
const { setSql, values } = this.buildUpdateSet(updated, now);
|
|
946
|
+
this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
|
|
947
|
+
recorder?.recordLocal(this.name, row._id, "upsert", now);
|
|
948
|
+
return {
|
|
949
|
+
...updated,
|
|
950
|
+
_id: row._id,
|
|
951
|
+
created_at: row.created_at,
|
|
952
|
+
updated_at: now
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
const ins = this.buildInsert(args.create);
|
|
956
|
+
this.db.prepare(this.insertSql()).run(...ins.values);
|
|
957
|
+
recorder?.recordLocal(this.name, ins._id, "upsert", ins.created_at);
|
|
958
|
+
return ins.returned;
|
|
959
|
+
})
|
|
960
|
+
);
|
|
642
961
|
}
|
|
643
962
|
/* ----------------------------- delete ----------------------------- */
|
|
644
963
|
runDelete(where, single) {
|
|
645
964
|
this.ensureTable();
|
|
646
965
|
const params = [];
|
|
647
|
-
const clause = buildWhere(where, {
|
|
648
|
-
|
|
966
|
+
const clause = buildWhere(where, {
|
|
967
|
+
params,
|
|
968
|
+
onPath: this.trackPath,
|
|
969
|
+
columns: this.columns
|
|
970
|
+
});
|
|
971
|
+
let selectSql = `SELECT * FROM "${this.name}" WHERE ${clause}`;
|
|
649
972
|
if (single) selectSql += " LIMIT 1";
|
|
650
973
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
651
974
|
if (!rows.length) return [];
|
|
652
975
|
const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
|
|
653
|
-
this.
|
|
654
|
-
|
|
655
|
-
|
|
976
|
+
const recorder = this.recorder;
|
|
977
|
+
const now = Date.now();
|
|
978
|
+
this.guard(
|
|
979
|
+
() => this.db.transaction(() => {
|
|
980
|
+
for (const row of rows) {
|
|
981
|
+
stmt.run(row._id);
|
|
982
|
+
recorder?.recordLocal(this.name, row._id, "delete", now);
|
|
983
|
+
}
|
|
984
|
+
})
|
|
985
|
+
);
|
|
656
986
|
return rows.map((r) => this.rowToDoc(r));
|
|
657
987
|
}
|
|
658
988
|
async delete(args) {
|
|
@@ -664,16 +994,20 @@ var Collection = class {
|
|
|
664
994
|
/* --------------------------- aggregation -------------------------- */
|
|
665
995
|
async aggregate(args = {}) {
|
|
666
996
|
this.ensureTable();
|
|
667
|
-
return
|
|
668
|
-
|
|
669
|
-
|
|
997
|
+
return this.guard(
|
|
998
|
+
() => aggregate(
|
|
999
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
1000
|
+
args
|
|
1001
|
+
)
|
|
670
1002
|
);
|
|
671
1003
|
}
|
|
672
1004
|
async groupBy(args) {
|
|
673
1005
|
this.ensureTable();
|
|
674
|
-
return
|
|
675
|
-
|
|
676
|
-
|
|
1006
|
+
return this.guard(
|
|
1007
|
+
() => groupBy(
|
|
1008
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
1009
|
+
args
|
|
1010
|
+
)
|
|
677
1011
|
);
|
|
678
1012
|
}
|
|
679
1013
|
};
|
|
@@ -731,15 +1065,19 @@ var AutoIndexer = class {
|
|
|
731
1065
|
};
|
|
732
1066
|
|
|
733
1067
|
// src/driver/better-sqlite3.ts
|
|
1068
|
+
var STMT_CACHE_MAX = 256;
|
|
734
1069
|
var BetterSqlite3Driver = class {
|
|
735
1070
|
name = "better-sqlite3";
|
|
736
1071
|
raw;
|
|
737
1072
|
verbose;
|
|
1073
|
+
cache = /* @__PURE__ */ new Map();
|
|
738
1074
|
constructor(BetterSqlite3, filename, options) {
|
|
739
1075
|
this.verbose = options.verbose;
|
|
740
1076
|
this.raw = new BetterSqlite3(filename, {
|
|
741
1077
|
readonly: options.readonly ?? false
|
|
742
1078
|
});
|
|
1079
|
+
this.raw.pragma("foreign_keys = ON");
|
|
1080
|
+
this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
|
|
743
1081
|
if (!options.readonly && (options.wal ?? true)) {
|
|
744
1082
|
this.raw.pragma("journal_mode = WAL");
|
|
745
1083
|
}
|
|
@@ -750,21 +1088,35 @@ var BetterSqlite3Driver = class {
|
|
|
750
1088
|
}
|
|
751
1089
|
prepare(sql) {
|
|
752
1090
|
this.verbose?.(sql);
|
|
753
|
-
|
|
1091
|
+
const cached = this.cache.get(sql);
|
|
1092
|
+
if (cached) return cached;
|
|
1093
|
+
const stmt = this.raw.prepare(sql);
|
|
1094
|
+
this.cacheStmt(sql, stmt);
|
|
1095
|
+
return stmt;
|
|
1096
|
+
}
|
|
1097
|
+
cacheStmt(sql, stmt) {
|
|
1098
|
+
if (this.cache.size >= STMT_CACHE_MAX) {
|
|
1099
|
+
const oldest = this.cache.keys().next().value;
|
|
1100
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
1101
|
+
}
|
|
1102
|
+
this.cache.set(sql, stmt);
|
|
754
1103
|
}
|
|
755
1104
|
transaction(fn) {
|
|
756
1105
|
return this.raw.transaction(fn)();
|
|
757
1106
|
}
|
|
758
1107
|
close() {
|
|
1108
|
+
this.cache.clear();
|
|
759
1109
|
this.raw.close();
|
|
760
1110
|
}
|
|
761
1111
|
};
|
|
762
1112
|
|
|
763
1113
|
// src/driver/node-sqlite.ts
|
|
1114
|
+
var STMT_CACHE_MAX2 = 256;
|
|
764
1115
|
var NodeSqliteDriver = class {
|
|
765
1116
|
name = "node:sqlite";
|
|
766
1117
|
raw;
|
|
767
1118
|
verbose;
|
|
1119
|
+
cache = /* @__PURE__ */ new Map();
|
|
768
1120
|
depth = 0;
|
|
769
1121
|
constructor(nodeSqlite, filename, options) {
|
|
770
1122
|
this.verbose = options.verbose;
|
|
@@ -772,6 +1124,7 @@ var NodeSqliteDriver = class {
|
|
|
772
1124
|
this.raw = new DatabaseSync(filename, {
|
|
773
1125
|
readOnly: options.readonly ?? false
|
|
774
1126
|
});
|
|
1127
|
+
this.raw.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
|
|
775
1128
|
if (!options.readonly && (options.wal ?? true)) {
|
|
776
1129
|
this.raw.exec("PRAGMA journal_mode = WAL");
|
|
777
1130
|
}
|
|
@@ -782,12 +1135,20 @@ var NodeSqliteDriver = class {
|
|
|
782
1135
|
}
|
|
783
1136
|
prepare(sql) {
|
|
784
1137
|
this.verbose?.(sql);
|
|
1138
|
+
const cached = this.cache.get(sql);
|
|
1139
|
+
if (cached) return cached;
|
|
785
1140
|
const stmt = this.raw.prepare(sql);
|
|
786
|
-
|
|
1141
|
+
const wrapped = {
|
|
787
1142
|
run: (...p) => stmt.run(...p),
|
|
788
1143
|
get: (...p) => stmt.get(...p),
|
|
789
1144
|
all: (...p) => stmt.all(...p)
|
|
790
1145
|
};
|
|
1146
|
+
if (this.cache.size >= STMT_CACHE_MAX2) {
|
|
1147
|
+
const oldest = this.cache.keys().next().value;
|
|
1148
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
1149
|
+
}
|
|
1150
|
+
this.cache.set(sql, wrapped);
|
|
1151
|
+
return wrapped;
|
|
791
1152
|
}
|
|
792
1153
|
transaction(fn) {
|
|
793
1154
|
const savepoint = `monlite_sp_${this.depth}`;
|
|
@@ -802,12 +1163,21 @@ var NodeSqliteDriver = class {
|
|
|
802
1163
|
return result;
|
|
803
1164
|
} catch (err) {
|
|
804
1165
|
this.depth--;
|
|
805
|
-
|
|
806
|
-
|
|
1166
|
+
try {
|
|
1167
|
+
if (this.depth === 0) this.raw.exec("ROLLBACK");
|
|
1168
|
+
else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
|
|
1169
|
+
} catch {
|
|
1170
|
+
this.depth = 0;
|
|
1171
|
+
try {
|
|
1172
|
+
this.raw.exec("ROLLBACK");
|
|
1173
|
+
} catch {
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
807
1176
|
throw err;
|
|
808
1177
|
}
|
|
809
1178
|
}
|
|
810
1179
|
close() {
|
|
1180
|
+
this.cache.clear();
|
|
811
1181
|
this.raw.close();
|
|
812
1182
|
}
|
|
813
1183
|
};
|
|
@@ -858,6 +1228,360 @@ function createDriver(filename, options = {}) {
|
|
|
858
1228
|
);
|
|
859
1229
|
}
|
|
860
1230
|
|
|
1231
|
+
// src/sync/version.ts
|
|
1232
|
+
var TS_WIDTH = 15;
|
|
1233
|
+
var SEQ_WIDTH = 12;
|
|
1234
|
+
function makeVersion(ts, nodeId, seq) {
|
|
1235
|
+
const base = String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
|
|
1236
|
+
return seq === void 0 ? base : base + ":" + String(seq).padStart(SEQ_WIDTH, "0");
|
|
1237
|
+
}
|
|
1238
|
+
function compareVersions(a, b) {
|
|
1239
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1240
|
+
}
|
|
1241
|
+
function versionTs(v) {
|
|
1242
|
+
const i = v.indexOf(":");
|
|
1243
|
+
return Number(i === -1 ? v : v.slice(0, i));
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/sync/store.ts
|
|
1247
|
+
var NAME_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1248
|
+
function assertName(name) {
|
|
1249
|
+
if (!NAME_RE2.test(name)) throw new Error(`Invalid collection name "${name}"`);
|
|
1250
|
+
}
|
|
1251
|
+
function stripSystem2(obj) {
|
|
1252
|
+
const { _id, created_at, updated_at, ...rest } = obj;
|
|
1253
|
+
return rest;
|
|
1254
|
+
}
|
|
1255
|
+
var SyncStore = class {
|
|
1256
|
+
constructor(db, nodeId) {
|
|
1257
|
+
this.db = db;
|
|
1258
|
+
this.init();
|
|
1259
|
+
this.nodeId = this.resolveNodeId(nodeId);
|
|
1260
|
+
}
|
|
1261
|
+
db;
|
|
1262
|
+
nodeId;
|
|
1263
|
+
versionSeq = 0;
|
|
1264
|
+
init() {
|
|
1265
|
+
this.db.exec(`
|
|
1266
|
+
CREATE TABLE IF NOT EXISTS _monlite_changes (
|
|
1267
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1268
|
+
coll TEXT NOT NULL,
|
|
1269
|
+
doc_id TEXT NOT NULL,
|
|
1270
|
+
op TEXT NOT NULL,
|
|
1271
|
+
version TEXT NOT NULL,
|
|
1272
|
+
ts INTEGER NOT NULL,
|
|
1273
|
+
source TEXT NOT NULL DEFAULT 'local',
|
|
1274
|
+
pushed INTEGER NOT NULL DEFAULT 0
|
|
1275
|
+
);
|
|
1276
|
+
CREATE INDEX IF NOT EXISTS _idx_changes_doc ON _monlite_changes(coll, doc_id, seq);
|
|
1277
|
+
CREATE INDEX IF NOT EXISTS _idx_changes_push ON _monlite_changes(source, pushed, seq);
|
|
1278
|
+
CREATE TABLE IF NOT EXISTS _monlite_sync_state (
|
|
1279
|
+
remote TEXT PRIMARY KEY,
|
|
1280
|
+
cursor TEXT,
|
|
1281
|
+
last_pull_at INTEGER,
|
|
1282
|
+
last_push_seq INTEGER,
|
|
1283
|
+
last_push_at INTEGER
|
|
1284
|
+
);
|
|
1285
|
+
CREATE TABLE IF NOT EXISTS _monlite_conflicts (
|
|
1286
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1287
|
+
coll TEXT, doc_id TEXT,
|
|
1288
|
+
local_version TEXT, remote_version TEXT,
|
|
1289
|
+
winner TEXT, ts INTEGER
|
|
1290
|
+
);
|
|
1291
|
+
CREATE TABLE IF NOT EXISTS _monlite_meta (key TEXT PRIMARY KEY, value TEXT);
|
|
1292
|
+
`);
|
|
1293
|
+
}
|
|
1294
|
+
resolveNodeId(explicit) {
|
|
1295
|
+
if (explicit) {
|
|
1296
|
+
this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
|
|
1297
|
+
return explicit;
|
|
1298
|
+
}
|
|
1299
|
+
const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
|
|
1300
|
+
if (row?.value) return row.value;
|
|
1301
|
+
const generated = objectId();
|
|
1302
|
+
this.db.prepare(`INSERT INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(generated);
|
|
1303
|
+
return generated;
|
|
1304
|
+
}
|
|
1305
|
+
/** True if this database tracks sync metadata (always, once constructed). */
|
|
1306
|
+
get enabled() {
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
/* ----------------------- local change recording ----------------------- */
|
|
1310
|
+
/** Append a locally-originated change to the feed. Call inside a write txn. */
|
|
1311
|
+
recordLocal(collection, id, op, ts) {
|
|
1312
|
+
const version = makeVersion(ts, this.nodeId, this.versionSeq++);
|
|
1313
|
+
this.db.prepare(
|
|
1314
|
+
`INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
|
|
1315
|
+
VALUES (?, ?, ?, ?, ?, 'local', 0)`
|
|
1316
|
+
).run(collection, id, op, version, ts);
|
|
1317
|
+
return version;
|
|
1318
|
+
}
|
|
1319
|
+
/** Current (latest) version of a document, or null if never recorded. */
|
|
1320
|
+
currentVersion(collection, id) {
|
|
1321
|
+
const row = this.db.prepare(
|
|
1322
|
+
`SELECT version FROM _monlite_changes
|
|
1323
|
+
WHERE coll = ? AND doc_id = ? ORDER BY seq DESC LIMIT 1`
|
|
1324
|
+
).get(collection, id);
|
|
1325
|
+
return row?.version ?? null;
|
|
1326
|
+
}
|
|
1327
|
+
/* ----------------------------- push side ----------------------------- */
|
|
1328
|
+
/** Latest unpushed local change per document (the push queue). */
|
|
1329
|
+
pending(collections, limit) {
|
|
1330
|
+
const params = [];
|
|
1331
|
+
let collFilter = "";
|
|
1332
|
+
if (collections && collections.length) {
|
|
1333
|
+
collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
|
|
1334
|
+
params.push(...collections);
|
|
1335
|
+
}
|
|
1336
|
+
let limitClause = "";
|
|
1337
|
+
if (limit != null && limit > 0) {
|
|
1338
|
+
limitClause = " LIMIT ?";
|
|
1339
|
+
params.push(limit);
|
|
1340
|
+
}
|
|
1341
|
+
const rows = this.db.prepare(
|
|
1342
|
+
`SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
|
|
1343
|
+
FROM _monlite_changes c
|
|
1344
|
+
JOIN (
|
|
1345
|
+
SELECT coll, doc_id, MAX(seq) AS mseq
|
|
1346
|
+
FROM _monlite_changes
|
|
1347
|
+
WHERE source = 'local' AND pushed = 0${collFilter}
|
|
1348
|
+
GROUP BY coll, doc_id
|
|
1349
|
+
) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
|
|
1350
|
+
ORDER BY c.seq${limitClause}`
|
|
1351
|
+
).all(...params);
|
|
1352
|
+
return rows.map((r) => {
|
|
1353
|
+
const change = {
|
|
1354
|
+
seq: r.seq,
|
|
1355
|
+
collection: r.coll,
|
|
1356
|
+
_id: r.doc_id,
|
|
1357
|
+
op: r.op,
|
|
1358
|
+
version: r.version,
|
|
1359
|
+
ts: r.ts
|
|
1360
|
+
};
|
|
1361
|
+
if (r.op === "upsert") {
|
|
1362
|
+
const doc = this.readDoc(r.coll, r.doc_id);
|
|
1363
|
+
if (doc) change.doc = doc;
|
|
1364
|
+
else change.op = "delete";
|
|
1365
|
+
}
|
|
1366
|
+
return change;
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
/** Mark the given changes (and any earlier local rows per doc) as pushed. */
|
|
1370
|
+
markPushed(changes) {
|
|
1371
|
+
if (!changes.length) return;
|
|
1372
|
+
const stmt = this.db.prepare(
|
|
1373
|
+
`UPDATE _monlite_changes SET pushed = 1
|
|
1374
|
+
WHERE coll = ? AND doc_id = ? AND seq <= ? AND source = 'local'`
|
|
1375
|
+
);
|
|
1376
|
+
this.db.transaction(() => {
|
|
1377
|
+
for (const c of changes) stmt.run(c.collection, c._id, c.seq);
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
/* ----------------------------- pull side ----------------------------- */
|
|
1381
|
+
/**
|
|
1382
|
+
* Apply a remote change, resolving conflicts against the local version.
|
|
1383
|
+
* Remote-applied changes are recorded with `source='remote'` so they are
|
|
1384
|
+
* never pushed back (echo prevention).
|
|
1385
|
+
*/
|
|
1386
|
+
applyRemote(change, resolver) {
|
|
1387
|
+
assertName(change.collection);
|
|
1388
|
+
return this.db.transaction(() => {
|
|
1389
|
+
const localVersion = this.currentVersion(change.collection, change._id);
|
|
1390
|
+
let winner;
|
|
1391
|
+
if (localVersion === null) {
|
|
1392
|
+
winner = "remote";
|
|
1393
|
+
} else if (change.version === localVersion) {
|
|
1394
|
+
return { applied: false, conflict: false, winner: "none" };
|
|
1395
|
+
} else {
|
|
1396
|
+
winner = resolver ? resolver({
|
|
1397
|
+
collection: change.collection,
|
|
1398
|
+
_id: change._id,
|
|
1399
|
+
local: { version: localVersion },
|
|
1400
|
+
remote: { version: change.version, doc: change.doc }
|
|
1401
|
+
}) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
|
|
1402
|
+
this.recordConflict(
|
|
1403
|
+
change.collection,
|
|
1404
|
+
change._id,
|
|
1405
|
+
localVersion,
|
|
1406
|
+
change.version,
|
|
1407
|
+
winner
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
if (winner !== "remote") {
|
|
1411
|
+
return { applied: false, conflict: localVersion !== null, winner };
|
|
1412
|
+
}
|
|
1413
|
+
this.applyData(change);
|
|
1414
|
+
this.db.prepare(
|
|
1415
|
+
`INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
|
|
1416
|
+
VALUES (?, ?, ?, ?, ?, 'remote', 1)`
|
|
1417
|
+
).run(
|
|
1418
|
+
change.collection,
|
|
1419
|
+
change._id,
|
|
1420
|
+
change.op,
|
|
1421
|
+
change.version,
|
|
1422
|
+
versionTs(change.version)
|
|
1423
|
+
);
|
|
1424
|
+
return { applied: true, conflict: localVersion !== null, winner: "remote" };
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
applyData(change) {
|
|
1428
|
+
const { collection: coll, _id, op } = change;
|
|
1429
|
+
this.ensureCollTable(coll);
|
|
1430
|
+
if (op === "delete") {
|
|
1431
|
+
this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
const doc = change.doc ?? {};
|
|
1435
|
+
const data = JSON.stringify(stripSystem2(doc));
|
|
1436
|
+
const ts = versionTs(change.version);
|
|
1437
|
+
const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
|
|
1438
|
+
this.db.prepare(
|
|
1439
|
+
`INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
|
|
1440
|
+
ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
|
|
1441
|
+
).run(_id, data, createdAt, ts);
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Latest change per document with `seq` greater than the given watermark,
|
|
1445
|
+
* as RemoteChanges (used when this database acts as a sync *source*, e.g. the
|
|
1446
|
+
* monlite-as-remote adapter). Returns the new watermark to resume from.
|
|
1447
|
+
*/
|
|
1448
|
+
changesSince(seq, collections, limit) {
|
|
1449
|
+
const params = [seq];
|
|
1450
|
+
let collFilter = "";
|
|
1451
|
+
if (collections && collections.length) {
|
|
1452
|
+
collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
|
|
1453
|
+
params.push(...collections);
|
|
1454
|
+
}
|
|
1455
|
+
let limitClause = "";
|
|
1456
|
+
if (limit != null && limit > 0) {
|
|
1457
|
+
limitClause = " LIMIT ?";
|
|
1458
|
+
params.push(limit);
|
|
1459
|
+
}
|
|
1460
|
+
const rows = this.db.prepare(
|
|
1461
|
+
`SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
|
|
1462
|
+
FROM _monlite_changes c
|
|
1463
|
+
JOIN (
|
|
1464
|
+
SELECT coll, doc_id, MAX(seq) AS mseq
|
|
1465
|
+
FROM _monlite_changes
|
|
1466
|
+
WHERE seq > ?${collFilter}
|
|
1467
|
+
GROUP BY coll, doc_id
|
|
1468
|
+
) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
|
|
1469
|
+
ORDER BY c.seq${limitClause}`
|
|
1470
|
+
).all(...params);
|
|
1471
|
+
const changes = rows.map((r) => {
|
|
1472
|
+
const change = {
|
|
1473
|
+
collection: r.coll,
|
|
1474
|
+
_id: r.doc_id,
|
|
1475
|
+
op: r.op,
|
|
1476
|
+
version: r.version
|
|
1477
|
+
};
|
|
1478
|
+
if (r.op === "upsert") {
|
|
1479
|
+
const doc = this.readDoc(r.coll, r.doc_id);
|
|
1480
|
+
if (doc) change.doc = doc;
|
|
1481
|
+
else change.op = "delete";
|
|
1482
|
+
}
|
|
1483
|
+
return change;
|
|
1484
|
+
});
|
|
1485
|
+
const maxSeq = rows.length ? rows[rows.length - 1].seq : seq;
|
|
1486
|
+
return { changes, maxSeq };
|
|
1487
|
+
}
|
|
1488
|
+
/* ------------------------------ bootstrap ----------------------------- */
|
|
1489
|
+
/**
|
|
1490
|
+
* Enqueue existing documents (created before sync was enabled, or never
|
|
1491
|
+
* recorded) as local upserts so they can be pushed. Idempotent.
|
|
1492
|
+
*/
|
|
1493
|
+
seed(collections) {
|
|
1494
|
+
let count = 0;
|
|
1495
|
+
this.db.transaction(() => {
|
|
1496
|
+
for (const coll of collections) {
|
|
1497
|
+
assertName(coll);
|
|
1498
|
+
const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
|
|
1499
|
+
for (const d of docs) {
|
|
1500
|
+
if (this.currentVersion(coll, d._id) !== null) continue;
|
|
1501
|
+
this.recordLocal(coll, d._id, "upsert", d.updated_at);
|
|
1502
|
+
count++;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
return count;
|
|
1507
|
+
}
|
|
1508
|
+
/* ------------------------------- state -------------------------------- */
|
|
1509
|
+
getState(remote) {
|
|
1510
|
+
const row = this.db.prepare(`SELECT * FROM _monlite_sync_state WHERE remote = ?`).get(remote);
|
|
1511
|
+
return {
|
|
1512
|
+
remote,
|
|
1513
|
+
cursor: row?.cursor ?? null,
|
|
1514
|
+
lastPullAt: row?.last_pull_at ?? null,
|
|
1515
|
+
lastPushSeq: row?.last_push_seq ?? null,
|
|
1516
|
+
lastPushAt: row?.last_push_at ?? null
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
setState(remote, patch) {
|
|
1520
|
+
const cur = this.getState(remote);
|
|
1521
|
+
const next = { ...cur, ...patch };
|
|
1522
|
+
this.db.prepare(
|
|
1523
|
+
`INSERT INTO _monlite_sync_state (remote, cursor, last_pull_at, last_push_seq, last_push_at)
|
|
1524
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1525
|
+
ON CONFLICT(remote) DO UPDATE SET
|
|
1526
|
+
cursor = excluded.cursor,
|
|
1527
|
+
last_pull_at = excluded.last_pull_at,
|
|
1528
|
+
last_push_seq = excluded.last_push_seq,
|
|
1529
|
+
last_push_at = excluded.last_push_at`
|
|
1530
|
+
).run(
|
|
1531
|
+
remote,
|
|
1532
|
+
next.cursor,
|
|
1533
|
+
next.lastPullAt,
|
|
1534
|
+
next.lastPushSeq,
|
|
1535
|
+
next.lastPushAt
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
/* ----------------------------- conflicts ------------------------------ */
|
|
1539
|
+
recordConflict(coll, id, localVersion, remoteVersion, winner) {
|
|
1540
|
+
this.db.prepare(
|
|
1541
|
+
`INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
|
|
1542
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
1543
|
+
).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
|
|
1544
|
+
}
|
|
1545
|
+
conflicts() {
|
|
1546
|
+
const rows = this.db.prepare(
|
|
1547
|
+
`SELECT coll, doc_id, local_version, remote_version, winner, ts
|
|
1548
|
+
FROM _monlite_conflicts ORDER BY id`
|
|
1549
|
+
).all();
|
|
1550
|
+
return rows.map((r) => ({
|
|
1551
|
+
collection: r.coll,
|
|
1552
|
+
_id: r.doc_id,
|
|
1553
|
+
localVersion: r.local_version,
|
|
1554
|
+
remoteVersion: r.remote_version,
|
|
1555
|
+
winner: r.winner,
|
|
1556
|
+
ts: r.ts
|
|
1557
|
+
}));
|
|
1558
|
+
}
|
|
1559
|
+
/* ------------------------------ helpers ------------------------------- */
|
|
1560
|
+
readDoc(coll, id) {
|
|
1561
|
+
assertName(coll);
|
|
1562
|
+
const row = this.db.prepare(
|
|
1563
|
+
`SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
|
|
1564
|
+
).get(id);
|
|
1565
|
+
if (!row) return null;
|
|
1566
|
+
const doc = JSON.parse(row.data);
|
|
1567
|
+
doc._id = row._id;
|
|
1568
|
+
doc.created_at = row.created_at;
|
|
1569
|
+
doc.updated_at = row.updated_at;
|
|
1570
|
+
return doc;
|
|
1571
|
+
}
|
|
1572
|
+
ensureCollTable(coll) {
|
|
1573
|
+
assertName(coll);
|
|
1574
|
+
this.db.exec(
|
|
1575
|
+
`CREATE TABLE IF NOT EXISTS "${coll}" (
|
|
1576
|
+
_id TEXT PRIMARY KEY,
|
|
1577
|
+
data TEXT NOT NULL,
|
|
1578
|
+
created_at INTEGER NOT NULL,
|
|
1579
|
+
updated_at INTEGER NOT NULL
|
|
1580
|
+
)`
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
861
1585
|
// src/db.ts
|
|
862
1586
|
function validateName(name) {
|
|
863
1587
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
@@ -883,6 +1607,8 @@ var Monlite = class {
|
|
|
883
1607
|
driver;
|
|
884
1608
|
/** @internal */
|
|
885
1609
|
autoIndexer;
|
|
1610
|
+
/** @internal Sync metadata store; present only when `{ sync: true }`. */
|
|
1611
|
+
$sync;
|
|
886
1612
|
collections = /* @__PURE__ */ new Map();
|
|
887
1613
|
closed = false;
|
|
888
1614
|
constructor(filename, options = {}) {
|
|
@@ -890,6 +1616,7 @@ var Monlite = class {
|
|
|
890
1616
|
driver: options.driver,
|
|
891
1617
|
readonly: options.readonly,
|
|
892
1618
|
wal: options.wal,
|
|
1619
|
+
busyTimeout: options.busyTimeout,
|
|
893
1620
|
verbose: options.verbose
|
|
894
1621
|
});
|
|
895
1622
|
this.autoIndexer = new AutoIndexer(
|
|
@@ -897,6 +1624,13 @@ var Monlite = class {
|
|
|
897
1624
|
options.autoIndex ?? true,
|
|
898
1625
|
options.autoIndexAfter ?? 10
|
|
899
1626
|
);
|
|
1627
|
+
if (options.sync) {
|
|
1628
|
+
this.$sync = new SyncStore(this.driver, options.nodeId);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
/** Stable node id for LWW tie-breaking (only when sync is enabled). */
|
|
1632
|
+
get nodeId() {
|
|
1633
|
+
return this.$sync?.nodeId;
|
|
900
1634
|
}
|
|
901
1635
|
/** The underlying native database handle (escape hatch). */
|
|
902
1636
|
get sqlite() {
|
|
@@ -906,17 +1640,44 @@ var Monlite = class {
|
|
|
906
1640
|
get driverName() {
|
|
907
1641
|
return this.driver.name;
|
|
908
1642
|
}
|
|
909
|
-
/**
|
|
910
|
-
|
|
1643
|
+
/**
|
|
1644
|
+
* Get (or lazily create) a typed collection handle. Pass `{ schema }` to make
|
|
1645
|
+
* it a structured collection backed by native SQL columns; omit for the
|
|
1646
|
+
* default schema-free document mode. Options apply only on first access.
|
|
1647
|
+
*/
|
|
1648
|
+
collection(name, options) {
|
|
911
1649
|
this.assertOpen();
|
|
912
1650
|
validateName(name);
|
|
913
1651
|
let col = this.collections.get(name);
|
|
914
1652
|
if (!col) {
|
|
915
|
-
col = new Collection(this, name);
|
|
1653
|
+
col = new Collection(this, name, options);
|
|
916
1654
|
this.collections.set(name, col);
|
|
1655
|
+
} else if (options?.schema) {
|
|
1656
|
+
const requested = Object.keys(options.schema);
|
|
1657
|
+
const existing = new Set(col.columnNames);
|
|
1658
|
+
const sameShape = col.mode === "structured" && requested.length === existing.size && requested.every((c) => existing.has(c));
|
|
1659
|
+
if (!sameShape) {
|
|
1660
|
+
throw new MonliteError(
|
|
1661
|
+
`Collection "${name}" was already opened with a different schema/mode. A collection's storage mode is fixed on first access.`
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
917
1664
|
}
|
|
918
1665
|
return col;
|
|
919
1666
|
}
|
|
1667
|
+
/** Inspect a collection's physical columns (PRAGMA table_info). */
|
|
1668
|
+
$schema(name) {
|
|
1669
|
+
this.assertOpen();
|
|
1670
|
+
validateName(name);
|
|
1671
|
+
const rows = this.driver.prepare(`PRAGMA table_info("${name}")`).all();
|
|
1672
|
+
return Promise.resolve(
|
|
1673
|
+
rows.map((r) => ({
|
|
1674
|
+
name: r.name,
|
|
1675
|
+
type: r.type,
|
|
1676
|
+
notNull: !!r.notnull,
|
|
1677
|
+
primaryKey: !!r.pk
|
|
1678
|
+
}))
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
920
1681
|
/** Tagged-template SQL query returning rows. Values are safely parameterized. */
|
|
921
1682
|
$queryRaw(strings, ...values) {
|
|
922
1683
|
this.assertOpen();
|
|
@@ -934,7 +1695,11 @@ var Monlite = class {
|
|
|
934
1695
|
$executeRaw(strings, ...values) {
|
|
935
1696
|
this.assertOpen();
|
|
936
1697
|
const { sql, params } = buildTagged(strings, values);
|
|
937
|
-
|
|
1698
|
+
try {
|
|
1699
|
+
return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
throw normalizeDriverError(err);
|
|
1702
|
+
}
|
|
938
1703
|
}
|
|
939
1704
|
/** Like {@link $executeRaw} but takes a raw SQL string and positional params. */
|
|
940
1705
|
$executeRawUnsafe(sql, ...params) {
|
|
@@ -956,7 +1721,9 @@ var Monlite = class {
|
|
|
956
1721
|
this.assertOpen();
|
|
957
1722
|
const rows = this.driver.prepare(
|
|
958
1723
|
`SELECT name FROM sqlite_master
|
|
959
|
-
WHERE type='table'
|
|
1724
|
+
WHERE type='table'
|
|
1725
|
+
AND name NOT LIKE 'sqlite_%'
|
|
1726
|
+
AND name NOT LIKE '\\_monlite\\_%' ESCAPE '\\'
|
|
960
1727
|
ORDER BY name`
|
|
961
1728
|
).all();
|
|
962
1729
|
return Promise.resolve(rows.map((r) => r.name));
|
|
@@ -992,10 +1759,19 @@ function createDb(filename, options) {
|
|
|
992
1759
|
|
|
993
1760
|
exports.Collection = Collection;
|
|
994
1761
|
exports.Monlite = Monlite;
|
|
1762
|
+
exports.MonliteConstraintError = MonliteConstraintError;
|
|
995
1763
|
exports.MonliteError = MonliteError;
|
|
1764
|
+
exports.MonliteForeignKeyError = MonliteForeignKeyError;
|
|
1765
|
+
exports.MonliteNotNullError = MonliteNotNullError;
|
|
996
1766
|
exports.MonliteQueryError = MonliteQueryError;
|
|
1767
|
+
exports.MonliteUniqueConstraintError = MonliteUniqueConstraintError;
|
|
1768
|
+
exports.SyncStore = SyncStore;
|
|
1769
|
+
exports.compareVersions = compareVersions;
|
|
997
1770
|
exports.createDb = createDb;
|
|
998
1771
|
exports.isObjectId = isObjectId;
|
|
1772
|
+
exports.makeVersion = makeVersion;
|
|
1773
|
+
exports.normalizeDriverError = normalizeDriverError;
|
|
999
1774
|
exports.objectId = objectId;
|
|
1775
|
+
exports.versionTs = versionTs;
|
|
1000
1776
|
//# sourceMappingURL=index.cjs.map
|
|
1001
1777
|
//# sourceMappingURL=index.cjs.map
|