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