@monlite/core 0.2.0 → 0.5.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 +87 -0
- package/dist/index.cjs +689 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +219 -7
- package/dist/index.d.ts +219 -7
- package/dist/index.js +686 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -39,6 +39,9 @@ var RESERVED_FIELDS = /* @__PURE__ */ new Set(["_id", "created_at", "updated_at"
|
|
|
39
39
|
function isReserved(field) {
|
|
40
40
|
return RESERVED_FIELDS.has(field);
|
|
41
41
|
}
|
|
42
|
+
function isColumn(field, columns) {
|
|
43
|
+
return isReserved(field) || (columns?.has(field) ?? false);
|
|
44
|
+
}
|
|
42
45
|
function jsonPath(field) {
|
|
43
46
|
let path = "$";
|
|
44
47
|
for (const seg of field.split(".")) {
|
|
@@ -55,8 +58,8 @@ function jsonPath(field) {
|
|
|
55
58
|
function pathLiteral(field) {
|
|
56
59
|
return "'" + jsonPath(field).replace(/'/g, "''") + "'";
|
|
57
60
|
}
|
|
58
|
-
function fieldExpr(field) {
|
|
59
|
-
if (
|
|
61
|
+
function fieldExpr(field, columns) {
|
|
62
|
+
if (isColumn(field, columns)) return `"${field}"`;
|
|
60
63
|
return `json_extract(data, ${pathLiteral(field)})`;
|
|
61
64
|
}
|
|
62
65
|
function bindable(value) {
|
|
@@ -103,8 +106,8 @@ function isFilterObject(v) {
|
|
|
103
106
|
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date) && !Buffer.isBuffer(v) && (v.constructor === Object || v.constructor === void 0);
|
|
104
107
|
}
|
|
105
108
|
function translateField(field, condition, ctx) {
|
|
106
|
-
if (ctx.onPath && !
|
|
107
|
-
const expr = fieldExpr(field);
|
|
109
|
+
if (ctx.onPath && !isColumn(field, ctx.columns)) ctx.onPath(field);
|
|
110
|
+
const expr = fieldExpr(field, ctx.columns);
|
|
108
111
|
if (!isFilterObject(condition)) {
|
|
109
112
|
return eqExpr(expr, condition, ctx);
|
|
110
113
|
}
|
|
@@ -154,7 +157,7 @@ function translateField(field, condition, ctx) {
|
|
|
154
157
|
clauses.push(hasExpr(field, expr, v, ctx));
|
|
155
158
|
break;
|
|
156
159
|
case "exists":
|
|
157
|
-
clauses.push(existsExpr(field, expr, !!v));
|
|
160
|
+
clauses.push(existsExpr(field, expr, !!v, ctx.columns));
|
|
158
161
|
break;
|
|
159
162
|
default:
|
|
160
163
|
throw new MonliteQueryError(
|
|
@@ -193,7 +196,7 @@ function inExpr(expr, arr, ctx, negate) {
|
|
|
193
196
|
return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
|
|
194
197
|
}
|
|
195
198
|
function containsExpr(field, expr, v, ctx) {
|
|
196
|
-
if (
|
|
199
|
+
if (isColumn(field, ctx.columns)) {
|
|
197
200
|
ctx.params.push(bindable(v));
|
|
198
201
|
return `instr(${expr}, ?) > 0`;
|
|
199
202
|
}
|
|
@@ -204,11 +207,11 @@ function containsExpr(field, expr, v, ctx) {
|
|
|
204
207
|
}
|
|
205
208
|
function hasExpr(field, expr, v, ctx) {
|
|
206
209
|
ctx.params.push(bindable(v));
|
|
207
|
-
if (
|
|
210
|
+
if (isColumn(field, ctx.columns)) return `${expr} = ?`;
|
|
208
211
|
return `EXISTS (SELECT 1 FROM json_each(data, ${pathLiteral(field)}) WHERE value = ?)`;
|
|
209
212
|
}
|
|
210
|
-
function existsExpr(field, expr, want) {
|
|
211
|
-
if (
|
|
213
|
+
function existsExpr(field, expr, want, columns) {
|
|
214
|
+
if (isColumn(field, columns)) {
|
|
212
215
|
return want ? `${expr} IS NOT NULL` : `${expr} IS NULL`;
|
|
213
216
|
}
|
|
214
217
|
const path = pathLiteral(field);
|
|
@@ -216,7 +219,7 @@ function existsExpr(field, expr, want) {
|
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
// src/query/order.ts
|
|
219
|
-
function buildOrderBy(orderBy, onPath) {
|
|
222
|
+
function buildOrderBy(orderBy, onPath, columns) {
|
|
220
223
|
if (!orderBy) return "";
|
|
221
224
|
const list = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
222
225
|
const parts = [];
|
|
@@ -224,9 +227,9 @@ function buildOrderBy(orderBy, onPath) {
|
|
|
224
227
|
for (const field of Object.keys(obj)) {
|
|
225
228
|
const dir = obj[field];
|
|
226
229
|
if (dir === void 0) continue;
|
|
227
|
-
if (onPath && !
|
|
230
|
+
if (onPath && !isColumn(field, columns)) onPath(field);
|
|
228
231
|
const d = String(dir).toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
229
|
-
parts.push(`${fieldExpr(field)} ${d}`);
|
|
232
|
+
parts.push(`${fieldExpr(field, columns)} ${d}`);
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
return parts.length ? "ORDER BY " + parts.join(", ") : "";
|
|
@@ -347,7 +350,7 @@ var SQL_FN = {
|
|
|
347
350
|
_min: "MIN",
|
|
348
351
|
_max: "MAX"
|
|
349
352
|
};
|
|
350
|
-
function buildAccumulators(args, onPath) {
|
|
353
|
+
function buildAccumulators(args, onPath, columns) {
|
|
351
354
|
const selects = [];
|
|
352
355
|
const cols = [];
|
|
353
356
|
let i = 0;
|
|
@@ -356,9 +359,9 @@ function buildAccumulators(args, onPath) {
|
|
|
356
359
|
if (!selection) continue;
|
|
357
360
|
for (const field of Object.keys(selection)) {
|
|
358
361
|
if (!selection[field]) continue;
|
|
359
|
-
onPath(field);
|
|
362
|
+
if (!isColumn(field, columns)) onPath(field);
|
|
360
363
|
const alias = `agg_${kind.slice(1)}_${i++}`;
|
|
361
|
-
selects.push(`${SQL_FN[kind]}(${fieldExpr(field)}) AS ${alias}`);
|
|
364
|
+
selects.push(`${SQL_FN[kind]}(${fieldExpr(field, columns)}) AS ${alias}`);
|
|
362
365
|
cols.push({ alias, kind, field });
|
|
363
366
|
}
|
|
364
367
|
}
|
|
@@ -366,8 +369,12 @@ function buildAccumulators(args, onPath) {
|
|
|
366
369
|
}
|
|
367
370
|
function aggregate(ctx, args) {
|
|
368
371
|
const params = [];
|
|
369
|
-
const where = buildWhere(args.where, {
|
|
370
|
-
|
|
372
|
+
const where = buildWhere(args.where, {
|
|
373
|
+
params,
|
|
374
|
+
onPath: ctx.onPath,
|
|
375
|
+
columns: ctx.columns
|
|
376
|
+
});
|
|
377
|
+
const { selects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
|
|
371
378
|
const allSelects = [`COUNT(*) AS agg_count`, ...selects];
|
|
372
379
|
const sql = `SELECT ${allSelects.join(", ")} FROM "${ctx.table}" WHERE ${where}`;
|
|
373
380
|
const row = ctx.db.prepare(sql).get(...params) ?? {};
|
|
@@ -379,30 +386,78 @@ function aggregate(ctx, args) {
|
|
|
379
386
|
}
|
|
380
387
|
return result;
|
|
381
388
|
}
|
|
389
|
+
var HAVING_FNS = [
|
|
390
|
+
["_sum", "SUM"],
|
|
391
|
+
["_avg", "AVG"],
|
|
392
|
+
["_min", "MIN"],
|
|
393
|
+
["_max", "MAX"]
|
|
394
|
+
];
|
|
395
|
+
function comparisonSql(expr, cmp2, params) {
|
|
396
|
+
const out = [];
|
|
397
|
+
const ops = [
|
|
398
|
+
["equals", "="],
|
|
399
|
+
["not", "<>"],
|
|
400
|
+
["gt", ">"],
|
|
401
|
+
["gte", ">="],
|
|
402
|
+
["lt", "<"],
|
|
403
|
+
["lte", "<="]
|
|
404
|
+
];
|
|
405
|
+
for (const [key, op] of ops) {
|
|
406
|
+
const v = cmp2[key];
|
|
407
|
+
if (v === void 0) continue;
|
|
408
|
+
params.push(v);
|
|
409
|
+
out.push(`${expr} ${op} ?`);
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function buildHaving(having, params, columns) {
|
|
414
|
+
const parts = [];
|
|
415
|
+
if (having._count) {
|
|
416
|
+
parts.push(...comparisonSql("COUNT(*)", having._count, params));
|
|
417
|
+
}
|
|
418
|
+
for (const [kind, fn] of HAVING_FNS) {
|
|
419
|
+
const selection = having[kind];
|
|
420
|
+
if (!selection) continue;
|
|
421
|
+
for (const field of Object.keys(selection)) {
|
|
422
|
+
parts.push(
|
|
423
|
+
...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return parts.join(" AND ");
|
|
428
|
+
}
|
|
382
429
|
function groupBy(ctx, args) {
|
|
383
430
|
if (!Array.isArray(args.by) || args.by.length === 0) {
|
|
384
431
|
throw new Error("groupBy requires a non-empty `by` array");
|
|
385
432
|
}
|
|
386
433
|
const params = [];
|
|
387
|
-
const where = buildWhere(args.where, {
|
|
434
|
+
const where = buildWhere(args.where, {
|
|
435
|
+
params,
|
|
436
|
+
onPath: ctx.onPath,
|
|
437
|
+
columns: ctx.columns
|
|
438
|
+
});
|
|
388
439
|
const groupExprs = [];
|
|
389
440
|
const selects = [];
|
|
390
441
|
for (const field of args.by) {
|
|
391
|
-
ctx.onPath(field);
|
|
392
|
-
const expr = fieldExpr(field);
|
|
442
|
+
if (!isColumn(field, ctx.columns)) ctx.onPath(field);
|
|
443
|
+
const expr = fieldExpr(field, ctx.columns);
|
|
393
444
|
groupExprs.push(expr);
|
|
394
445
|
selects.push(`${expr} AS "${field}"`);
|
|
395
446
|
}
|
|
396
447
|
selects.push(`COUNT(*) AS agg_count`);
|
|
397
|
-
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath);
|
|
448
|
+
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
|
|
398
449
|
selects.push(...accSelects);
|
|
399
450
|
let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
|
|
451
|
+
if (args.having) {
|
|
452
|
+
const havingSql = buildHaving(args.having, params, ctx.columns);
|
|
453
|
+
if (havingSql) sql += ` HAVING ${havingSql}`;
|
|
454
|
+
}
|
|
400
455
|
if (args.orderBy) {
|
|
401
456
|
const parts = [];
|
|
402
457
|
for (const key of Object.keys(args.orderBy)) {
|
|
403
458
|
const dir = String(args.orderBy[key]).toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
404
459
|
if (key === "_count") parts.push(`agg_count ${dir}`);
|
|
405
|
-
else parts.push(`${fieldExpr(key)} ${dir}`);
|
|
460
|
+
else parts.push(`${fieldExpr(key, ctx.columns)} ${dir}`);
|
|
406
461
|
}
|
|
407
462
|
if (parts.length) sql += ` ORDER BY ${parts.join(", ")}`;
|
|
408
463
|
}
|
|
@@ -427,71 +482,216 @@ function groupBy(ctx, args) {
|
|
|
427
482
|
}
|
|
428
483
|
|
|
429
484
|
// src/collection.ts
|
|
430
|
-
var SELECT_COLS = `_id, data, created_at, updated_at`;
|
|
431
485
|
function stripSystem(obj) {
|
|
432
486
|
const { _id, created_at, updated_at, ...rest } = obj;
|
|
433
487
|
return rest;
|
|
434
488
|
}
|
|
489
|
+
var NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
490
|
+
function sqliteType(type) {
|
|
491
|
+
return type === "JSON" ? "TEXT" : type;
|
|
492
|
+
}
|
|
493
|
+
function formatDefault(value) {
|
|
494
|
+
if (value === null) return "NULL";
|
|
495
|
+
if (typeof value === "number") return String(value);
|
|
496
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
497
|
+
}
|
|
435
498
|
var Collection = class {
|
|
436
|
-
constructor(mon, name) {
|
|
499
|
+
constructor(mon, name, options = {}) {
|
|
437
500
|
this.mon = mon;
|
|
438
501
|
this.name = name;
|
|
502
|
+
this.mode = options.schema ? "structured" : "document";
|
|
503
|
+
if (options.schema) {
|
|
504
|
+
for (const [field, def] of Object.entries(options.schema)) {
|
|
505
|
+
if (!NAME_RE.test(field)) {
|
|
506
|
+
throw new MonliteError(`Invalid column name "${field}"`);
|
|
507
|
+
}
|
|
508
|
+
if (RESERVED_FIELDS.has(field) || field === "data") {
|
|
509
|
+
throw new MonliteError(
|
|
510
|
+
`Column "${field}" is reserved by monlite and cannot be declared`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
const normalized = typeof def === "string" ? { type: def } : def;
|
|
514
|
+
this.columnDefs[field] = normalized;
|
|
515
|
+
this.columnOrder.push(field);
|
|
516
|
+
this.columns.add(field);
|
|
517
|
+
if (normalized.type === "JSON") this.jsonColumns.add(field);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
439
520
|
}
|
|
440
521
|
mon;
|
|
441
522
|
name;
|
|
523
|
+
mode;
|
|
442
524
|
initialized = false;
|
|
525
|
+
columnDefs = {};
|
|
526
|
+
columnOrder = [];
|
|
527
|
+
/** Declared native columns (empty in document mode). */
|
|
528
|
+
columns = /* @__PURE__ */ new Set();
|
|
529
|
+
jsonColumns = /* @__PURE__ */ new Set();
|
|
530
|
+
insertSqlCache;
|
|
443
531
|
trackPath = (path) => this.mon.autoIndexer.track(this.name, path);
|
|
444
532
|
get db() {
|
|
445
533
|
return this.mon.driver;
|
|
446
534
|
}
|
|
535
|
+
/** Native column names declared for this collection (structured mode). */
|
|
536
|
+
get columnNames() {
|
|
537
|
+
return [...this.columnOrder];
|
|
538
|
+
}
|
|
447
539
|
ensureTable() {
|
|
448
540
|
if (this.initialized) return;
|
|
449
|
-
this.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
541
|
+
if (this.mode === "document") {
|
|
542
|
+
this.db.exec(
|
|
543
|
+
`CREATE TABLE IF NOT EXISTS "${this.name}" (
|
|
544
|
+
_id TEXT PRIMARY KEY,
|
|
545
|
+
data TEXT NOT NULL,
|
|
546
|
+
created_at INTEGER NOT NULL,
|
|
547
|
+
updated_at INTEGER NOT NULL
|
|
548
|
+
)`
|
|
549
|
+
);
|
|
550
|
+
} else {
|
|
551
|
+
const lines = [
|
|
552
|
+
`_id TEXT PRIMARY KEY`,
|
|
553
|
+
`created_at INTEGER NOT NULL`,
|
|
554
|
+
`updated_at INTEGER NOT NULL`,
|
|
555
|
+
`data TEXT NOT NULL DEFAULT '{}'`
|
|
556
|
+
];
|
|
557
|
+
for (const field of this.columnOrder) {
|
|
558
|
+
const def = this.columnDefs[field];
|
|
559
|
+
let line = `"${field}" ${sqliteType(def.type)}`;
|
|
560
|
+
if (def.notNull) line += " NOT NULL";
|
|
561
|
+
if (def.unique) line += " UNIQUE";
|
|
562
|
+
if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
|
|
563
|
+
if (def.references) line += ` REFERENCES ${def.references}`;
|
|
564
|
+
lines.push(line);
|
|
565
|
+
}
|
|
566
|
+
this.db.exec(
|
|
567
|
+
`CREATE TABLE IF NOT EXISTS "${this.name}" (
|
|
568
|
+
${lines.join(",\n ")}
|
|
569
|
+
)`
|
|
570
|
+
);
|
|
571
|
+
for (const field of this.columnOrder) {
|
|
572
|
+
if (this.columnDefs[field].index) {
|
|
573
|
+
this.db.exec(
|
|
574
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.name}_${field}" ON "${this.name}"("${field}")`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
457
579
|
this.initialized = true;
|
|
458
580
|
}
|
|
581
|
+
/* --------------------------- row <-> doc -------------------------- */
|
|
459
582
|
rowToDoc(row) {
|
|
460
|
-
const doc = JSON.parse(row.data);
|
|
583
|
+
const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
|
|
584
|
+
if (this.mode === "structured") {
|
|
585
|
+
for (const field of this.columnOrder) {
|
|
586
|
+
const value = row[field];
|
|
587
|
+
if (value === null || value === void 0) continue;
|
|
588
|
+
doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
461
591
|
doc._id = row._id;
|
|
462
592
|
doc.created_at = row.created_at;
|
|
463
593
|
doc.updated_at = row.updated_at;
|
|
464
594
|
return doc;
|
|
465
595
|
}
|
|
466
|
-
|
|
596
|
+
encodeColumn(field, value) {
|
|
597
|
+
if (this.jsonColumns.has(field)) {
|
|
598
|
+
return value === void 0 ? null : JSON.stringify(value);
|
|
599
|
+
}
|
|
600
|
+
return bindable(value);
|
|
601
|
+
}
|
|
602
|
+
insertColumns() {
|
|
603
|
+
return this.mode === "document" ? ["_id", "data", "created_at", "updated_at"] : ["_id", "created_at", "updated_at", "data", ...this.columnOrder];
|
|
604
|
+
}
|
|
605
|
+
insertSql() {
|
|
606
|
+
if (this.insertSqlCache) return this.insertSqlCache;
|
|
607
|
+
const cols = this.insertColumns();
|
|
608
|
+
const list = cols.map((c) => `"${c}"`).join(", ");
|
|
609
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
610
|
+
return this.insertSqlCache = `INSERT INTO "${this.name}" (${list}) VALUES (${placeholders})`;
|
|
611
|
+
}
|
|
612
|
+
/** Split an input document into a row aligned with `insertColumns()`. */
|
|
613
|
+
buildInsert(input) {
|
|
467
614
|
const now = Date.now();
|
|
468
615
|
const id = input._id != null ? String(input._id) : objectId();
|
|
469
616
|
const doc = stripSystem(input);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
617
|
+
const returned = { ...doc, _id: id, created_at: now, updated_at: now };
|
|
618
|
+
if (this.mode === "document") {
|
|
619
|
+
return {
|
|
620
|
+
_id: id,
|
|
621
|
+
created_at: now,
|
|
622
|
+
updated_at: now,
|
|
623
|
+
values: [id, JSON.stringify(doc), now, now],
|
|
624
|
+
returned
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const overflow = {};
|
|
628
|
+
const colValues = {};
|
|
629
|
+
for (const [k, v] of Object.entries(doc)) {
|
|
630
|
+
if (this.columns.has(k)) colValues[k] = v;
|
|
631
|
+
else overflow[k] = v;
|
|
632
|
+
}
|
|
633
|
+
const values = [
|
|
634
|
+
id,
|
|
635
|
+
now,
|
|
636
|
+
now,
|
|
637
|
+
JSON.stringify(overflow),
|
|
638
|
+
...this.columnOrder.map(
|
|
639
|
+
(c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
|
|
640
|
+
)
|
|
641
|
+
];
|
|
642
|
+
return { _id: id, created_at: now, updated_at: now, values, returned };
|
|
643
|
+
}
|
|
644
|
+
/** Build the `SET` clause + values to persist an updated document. */
|
|
645
|
+
buildUpdateSet(updatedDoc, now) {
|
|
646
|
+
if (this.mode === "document") {
|
|
647
|
+
return {
|
|
648
|
+
setSql: `data = ?, updated_at = ?`,
|
|
649
|
+
values: [JSON.stringify(updatedDoc), now]
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const overflow = {};
|
|
653
|
+
const colValues = {};
|
|
654
|
+
for (const [k, v] of Object.entries(updatedDoc)) {
|
|
655
|
+
if (this.columns.has(k)) colValues[k] = v;
|
|
656
|
+
else overflow[k] = v;
|
|
657
|
+
}
|
|
658
|
+
const setParts = this.columnOrder.map((c) => `"${c}" = ?`);
|
|
659
|
+
setParts.push(`data = ?`, `updated_at = ?`);
|
|
660
|
+
const values = [
|
|
661
|
+
...this.columnOrder.map(
|
|
662
|
+
(c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
|
|
663
|
+
),
|
|
664
|
+
JSON.stringify(overflow),
|
|
665
|
+
now
|
|
666
|
+
];
|
|
667
|
+
return { setSql: setParts.join(", "), values };
|
|
668
|
+
}
|
|
669
|
+
/** Sync store, but only for document collections (structured sync is future work). */
|
|
670
|
+
get recorder() {
|
|
671
|
+
return this.mode === "document" ? this.mon.$sync : void 0;
|
|
476
672
|
}
|
|
477
673
|
/* ----------------------------- create ----------------------------- */
|
|
478
674
|
async create(args) {
|
|
479
675
|
this.ensureTable();
|
|
480
|
-
const row = this.
|
|
481
|
-
this.
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
676
|
+
const row = this.buildInsert(args.data);
|
|
677
|
+
const recorder = this.recorder;
|
|
678
|
+
const write = () => {
|
|
679
|
+
this.db.prepare(this.insertSql()).run(...row.values);
|
|
680
|
+
recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
|
|
681
|
+
};
|
|
682
|
+
if (recorder) this.db.transaction(write);
|
|
683
|
+
else write();
|
|
684
|
+
return row.returned;
|
|
485
685
|
}
|
|
486
686
|
async createMany(args) {
|
|
487
687
|
this.ensureTable();
|
|
488
|
-
const stmt = this.db.prepare(
|
|
489
|
-
|
|
490
|
-
);
|
|
688
|
+
const stmt = this.db.prepare(this.insertSql());
|
|
689
|
+
const recorder = this.recorder;
|
|
491
690
|
this.db.transaction(() => {
|
|
492
691
|
for (const item of args.data) {
|
|
493
|
-
const row = this.
|
|
494
|
-
stmt.run(row.
|
|
692
|
+
const row = this.buildInsert(item);
|
|
693
|
+
stmt.run(...row.values);
|
|
694
|
+
recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
|
|
495
695
|
}
|
|
496
696
|
});
|
|
497
697
|
return { count: args.data.length };
|
|
@@ -500,9 +700,13 @@ var Collection = class {
|
|
|
500
700
|
async findMany(args = {}) {
|
|
501
701
|
this.ensureTable();
|
|
502
702
|
const params = [];
|
|
503
|
-
const where = buildWhere(args.where, {
|
|
504
|
-
|
|
505
|
-
|
|
703
|
+
const where = buildWhere(args.where, {
|
|
704
|
+
params,
|
|
705
|
+
onPath: this.trackPath,
|
|
706
|
+
columns: this.columns
|
|
707
|
+
});
|
|
708
|
+
let sql = `SELECT * FROM "${this.name}" WHERE ${where}`;
|
|
709
|
+
const order = buildOrderBy(args.orderBy, this.trackPath, this.columns);
|
|
506
710
|
if (order) sql += " " + order;
|
|
507
711
|
if (args.take != null) {
|
|
508
712
|
sql += " LIMIT ?";
|
|
@@ -513,9 +717,7 @@ var Collection = class {
|
|
|
513
717
|
params.push(args.skip);
|
|
514
718
|
}
|
|
515
719
|
const rows = this.db.prepare(sql).all(...params);
|
|
516
|
-
return rows.map(
|
|
517
|
-
(r) => project(this.rowToDoc(r), args.select)
|
|
518
|
-
);
|
|
720
|
+
return rows.map((r) => project(this.rowToDoc(r), args.select));
|
|
519
721
|
}
|
|
520
722
|
async findFirst(args = {}) {
|
|
521
723
|
const rows = await this.findMany({ ...args, take: 1 });
|
|
@@ -523,35 +725,65 @@ var Collection = class {
|
|
|
523
725
|
}
|
|
524
726
|
async findById(id) {
|
|
525
727
|
this.ensureTable();
|
|
526
|
-
const row = this.db.prepare(`SELECT
|
|
728
|
+
const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
|
|
527
729
|
return row ? this.rowToDoc(row) : null;
|
|
528
730
|
}
|
|
529
731
|
async count(args = {}) {
|
|
530
732
|
this.ensureTable();
|
|
531
733
|
const params = [];
|
|
532
|
-
const where = buildWhere(args.where, {
|
|
734
|
+
const where = buildWhere(args.where, {
|
|
735
|
+
params,
|
|
736
|
+
onPath: this.trackPath,
|
|
737
|
+
columns: this.columns
|
|
738
|
+
});
|
|
533
739
|
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM "${this.name}" WHERE ${where}`).get(...params);
|
|
534
740
|
return row.n;
|
|
535
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Return the distinct values of a field. Array fields stored in JSON are
|
|
744
|
+
* unwound (each element counts as a value), matching MongoDB's `distinct`.
|
|
745
|
+
*/
|
|
746
|
+
async distinct(field, where) {
|
|
747
|
+
this.ensureTable();
|
|
748
|
+
const params = [];
|
|
749
|
+
const clause = buildWhere(where, {
|
|
750
|
+
params,
|
|
751
|
+
onPath: this.trackPath,
|
|
752
|
+
columns: this.columns
|
|
753
|
+
});
|
|
754
|
+
let sql;
|
|
755
|
+
if (isColumn(field, this.columns)) {
|
|
756
|
+
sql = `SELECT DISTINCT ${fieldExpr(field, this.columns)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
|
|
757
|
+
} else {
|
|
758
|
+
this.trackPath(field);
|
|
759
|
+
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`;
|
|
760
|
+
}
|
|
761
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
762
|
+
return rows.map((r) => r.v);
|
|
763
|
+
}
|
|
536
764
|
/* ----------------------------- update ----------------------------- */
|
|
537
765
|
runUpdate(where, data, single) {
|
|
538
766
|
this.ensureTable();
|
|
539
767
|
const params = [];
|
|
540
|
-
const clause = buildWhere(where, {
|
|
541
|
-
|
|
768
|
+
const clause = buildWhere(where, {
|
|
769
|
+
params,
|
|
770
|
+
onPath: this.trackPath,
|
|
771
|
+
columns: this.columns
|
|
772
|
+
});
|
|
773
|
+
let selectSql = `SELECT * FROM "${this.name}" WHERE ${clause}`;
|
|
542
774
|
if (single) selectSql += " LIMIT 1";
|
|
543
775
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
544
776
|
if (!rows.length) return [];
|
|
545
777
|
const now = Date.now();
|
|
546
|
-
const
|
|
547
|
-
`UPDATE "${this.name}" SET data = ?, updated_at = ? WHERE _id = ?`
|
|
548
|
-
);
|
|
778
|
+
const recorder = this.recorder;
|
|
549
779
|
return this.db.transaction(() => {
|
|
550
780
|
const out = [];
|
|
551
781
|
for (const row of rows) {
|
|
552
|
-
const current =
|
|
782
|
+
const current = stripSystem(this.rowToDoc(row));
|
|
553
783
|
const updated = stripSystem(applyUpdate(current, data));
|
|
554
|
-
|
|
784
|
+
const { setSql, values } = this.buildUpdateSet(updated, now);
|
|
785
|
+
this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
|
|
786
|
+
recorder?.recordLocal(this.name, row._id, "upsert", now);
|
|
555
787
|
out.push({
|
|
556
788
|
...updated,
|
|
557
789
|
_id: row._id,
|
|
@@ -584,14 +816,23 @@ var Collection = class {
|
|
|
584
816
|
runDelete(where, single) {
|
|
585
817
|
this.ensureTable();
|
|
586
818
|
const params = [];
|
|
587
|
-
const clause = buildWhere(where, {
|
|
588
|
-
|
|
819
|
+
const clause = buildWhere(where, {
|
|
820
|
+
params,
|
|
821
|
+
onPath: this.trackPath,
|
|
822
|
+
columns: this.columns
|
|
823
|
+
});
|
|
824
|
+
let selectSql = `SELECT * FROM "${this.name}" WHERE ${clause}`;
|
|
589
825
|
if (single) selectSql += " LIMIT 1";
|
|
590
826
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
591
827
|
if (!rows.length) return [];
|
|
592
828
|
const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
|
|
829
|
+
const recorder = this.recorder;
|
|
830
|
+
const now = Date.now();
|
|
593
831
|
this.db.transaction(() => {
|
|
594
|
-
for (const row of rows)
|
|
832
|
+
for (const row of rows) {
|
|
833
|
+
stmt.run(row._id);
|
|
834
|
+
recorder?.recordLocal(this.name, row._id, "delete", now);
|
|
835
|
+
}
|
|
595
836
|
});
|
|
596
837
|
return rows.map((r) => this.rowToDoc(r));
|
|
597
838
|
}
|
|
@@ -605,14 +846,14 @@ var Collection = class {
|
|
|
605
846
|
async aggregate(args = {}) {
|
|
606
847
|
this.ensureTable();
|
|
607
848
|
return aggregate(
|
|
608
|
-
{ db: this.db, table: this.name, onPath: this.trackPath },
|
|
849
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
609
850
|
args
|
|
610
851
|
);
|
|
611
852
|
}
|
|
612
853
|
async groupBy(args) {
|
|
613
854
|
this.ensureTable();
|
|
614
855
|
return groupBy(
|
|
615
|
-
{ db: this.db, table: this.name, onPath: this.trackPath },
|
|
856
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
616
857
|
args
|
|
617
858
|
);
|
|
618
859
|
}
|
|
@@ -798,6 +1039,347 @@ function createDriver(filename, options = {}) {
|
|
|
798
1039
|
);
|
|
799
1040
|
}
|
|
800
1041
|
|
|
1042
|
+
// src/sync/version.ts
|
|
1043
|
+
var TS_WIDTH = 15;
|
|
1044
|
+
function makeVersion(ts, nodeId) {
|
|
1045
|
+
return String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
|
|
1046
|
+
}
|
|
1047
|
+
function compareVersions(a, b) {
|
|
1048
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1049
|
+
}
|
|
1050
|
+
function versionTs(v) {
|
|
1051
|
+
const i = v.indexOf(":");
|
|
1052
|
+
return Number(i === -1 ? v : v.slice(0, i));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/sync/store.ts
|
|
1056
|
+
var NAME_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1057
|
+
function assertName(name) {
|
|
1058
|
+
if (!NAME_RE2.test(name)) throw new Error(`Invalid collection name "${name}"`);
|
|
1059
|
+
}
|
|
1060
|
+
function stripSystem2(obj) {
|
|
1061
|
+
const { _id, created_at, updated_at, ...rest } = obj;
|
|
1062
|
+
return rest;
|
|
1063
|
+
}
|
|
1064
|
+
var SyncStore = class {
|
|
1065
|
+
constructor(db, nodeId) {
|
|
1066
|
+
this.db = db;
|
|
1067
|
+
this.init();
|
|
1068
|
+
this.nodeId = this.resolveNodeId(nodeId);
|
|
1069
|
+
}
|
|
1070
|
+
db;
|
|
1071
|
+
nodeId;
|
|
1072
|
+
init() {
|
|
1073
|
+
this.db.exec(`
|
|
1074
|
+
CREATE TABLE IF NOT EXISTS _monlite_changes (
|
|
1075
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1076
|
+
coll TEXT NOT NULL,
|
|
1077
|
+
doc_id TEXT NOT NULL,
|
|
1078
|
+
op TEXT NOT NULL,
|
|
1079
|
+
version TEXT NOT NULL,
|
|
1080
|
+
ts INTEGER NOT NULL,
|
|
1081
|
+
source TEXT NOT NULL DEFAULT 'local',
|
|
1082
|
+
pushed INTEGER NOT NULL DEFAULT 0
|
|
1083
|
+
);
|
|
1084
|
+
CREATE INDEX IF NOT EXISTS _idx_changes_doc ON _monlite_changes(coll, doc_id, seq);
|
|
1085
|
+
CREATE INDEX IF NOT EXISTS _idx_changes_push ON _monlite_changes(source, pushed, seq);
|
|
1086
|
+
CREATE TABLE IF NOT EXISTS _monlite_sync_state (
|
|
1087
|
+
remote TEXT PRIMARY KEY,
|
|
1088
|
+
cursor TEXT,
|
|
1089
|
+
last_pull_at INTEGER,
|
|
1090
|
+
last_push_seq INTEGER,
|
|
1091
|
+
last_push_at INTEGER
|
|
1092
|
+
);
|
|
1093
|
+
CREATE TABLE IF NOT EXISTS _monlite_conflicts (
|
|
1094
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1095
|
+
coll TEXT, doc_id TEXT,
|
|
1096
|
+
local_version TEXT, remote_version TEXT,
|
|
1097
|
+
winner TEXT, ts INTEGER
|
|
1098
|
+
);
|
|
1099
|
+
CREATE TABLE IF NOT EXISTS _monlite_meta (key TEXT PRIMARY KEY, value TEXT);
|
|
1100
|
+
`);
|
|
1101
|
+
}
|
|
1102
|
+
resolveNodeId(explicit) {
|
|
1103
|
+
if (explicit) {
|
|
1104
|
+
this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
|
|
1105
|
+
return explicit;
|
|
1106
|
+
}
|
|
1107
|
+
const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
|
|
1108
|
+
if (row?.value) return row.value;
|
|
1109
|
+
const generated = objectId();
|
|
1110
|
+
this.db.prepare(`INSERT INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(generated);
|
|
1111
|
+
return generated;
|
|
1112
|
+
}
|
|
1113
|
+
/** True if this database tracks sync metadata (always, once constructed). */
|
|
1114
|
+
get enabled() {
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
/* ----------------------- local change recording ----------------------- */
|
|
1118
|
+
/** Append a locally-originated change to the feed. Call inside a write txn. */
|
|
1119
|
+
recordLocal(collection, id, op, ts) {
|
|
1120
|
+
const version = makeVersion(ts, this.nodeId);
|
|
1121
|
+
this.db.prepare(
|
|
1122
|
+
`INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
|
|
1123
|
+
VALUES (?, ?, ?, ?, ?, 'local', 0)`
|
|
1124
|
+
).run(collection, id, op, version, ts);
|
|
1125
|
+
return version;
|
|
1126
|
+
}
|
|
1127
|
+
/** Current (latest) version of a document, or null if never recorded. */
|
|
1128
|
+
currentVersion(collection, id) {
|
|
1129
|
+
const row = this.db.prepare(
|
|
1130
|
+
`SELECT version FROM _monlite_changes
|
|
1131
|
+
WHERE coll = ? AND doc_id = ? ORDER BY seq DESC LIMIT 1`
|
|
1132
|
+
).get(collection, id);
|
|
1133
|
+
return row?.version ?? null;
|
|
1134
|
+
}
|
|
1135
|
+
/* ----------------------------- push side ----------------------------- */
|
|
1136
|
+
/** Latest unpushed local change per document (the push queue). */
|
|
1137
|
+
pending(collections) {
|
|
1138
|
+
const params = [];
|
|
1139
|
+
let collFilter = "";
|
|
1140
|
+
if (collections && collections.length) {
|
|
1141
|
+
collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
|
|
1142
|
+
params.push(...collections);
|
|
1143
|
+
}
|
|
1144
|
+
const rows = this.db.prepare(
|
|
1145
|
+
`SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
|
|
1146
|
+
FROM _monlite_changes c
|
|
1147
|
+
JOIN (
|
|
1148
|
+
SELECT coll, doc_id, MAX(seq) AS mseq
|
|
1149
|
+
FROM _monlite_changes
|
|
1150
|
+
WHERE source = 'local' AND pushed = 0${collFilter}
|
|
1151
|
+
GROUP BY coll, doc_id
|
|
1152
|
+
) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
|
|
1153
|
+
ORDER BY c.seq`
|
|
1154
|
+
).all(...params);
|
|
1155
|
+
return rows.map((r) => {
|
|
1156
|
+
const change = {
|
|
1157
|
+
seq: r.seq,
|
|
1158
|
+
collection: r.coll,
|
|
1159
|
+
_id: r.doc_id,
|
|
1160
|
+
op: r.op,
|
|
1161
|
+
version: r.version,
|
|
1162
|
+
ts: r.ts
|
|
1163
|
+
};
|
|
1164
|
+
if (r.op === "upsert") {
|
|
1165
|
+
const doc = this.readDoc(r.coll, r.doc_id);
|
|
1166
|
+
if (doc) change.doc = doc;
|
|
1167
|
+
else change.op = "delete";
|
|
1168
|
+
}
|
|
1169
|
+
return change;
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
/** Mark the given changes (and any earlier local rows per doc) as pushed. */
|
|
1173
|
+
markPushed(changes) {
|
|
1174
|
+
if (!changes.length) return;
|
|
1175
|
+
const stmt = this.db.prepare(
|
|
1176
|
+
`UPDATE _monlite_changes SET pushed = 1
|
|
1177
|
+
WHERE coll = ? AND doc_id = ? AND seq <= ? AND source = 'local'`
|
|
1178
|
+
);
|
|
1179
|
+
this.db.transaction(() => {
|
|
1180
|
+
for (const c of changes) stmt.run(c.collection, c._id, c.seq);
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
/* ----------------------------- pull side ----------------------------- */
|
|
1184
|
+
/**
|
|
1185
|
+
* Apply a remote change, resolving conflicts against the local version.
|
|
1186
|
+
* Remote-applied changes are recorded with `source='remote'` so they are
|
|
1187
|
+
* never pushed back (echo prevention).
|
|
1188
|
+
*/
|
|
1189
|
+
applyRemote(change, resolver) {
|
|
1190
|
+
assertName(change.collection);
|
|
1191
|
+
const localVersion = this.currentVersion(change.collection, change._id);
|
|
1192
|
+
let winner;
|
|
1193
|
+
if (localVersion === null) {
|
|
1194
|
+
winner = "remote";
|
|
1195
|
+
} else if (change.version === localVersion) {
|
|
1196
|
+
return { applied: false, conflict: false, winner: "none" };
|
|
1197
|
+
} else {
|
|
1198
|
+
winner = resolver ? resolver({
|
|
1199
|
+
collection: change.collection,
|
|
1200
|
+
_id: change._id,
|
|
1201
|
+
local: { version: localVersion },
|
|
1202
|
+
remote: { version: change.version, doc: change.doc }
|
|
1203
|
+
}) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
|
|
1204
|
+
this.recordConflict(
|
|
1205
|
+
change.collection,
|
|
1206
|
+
change._id,
|
|
1207
|
+
localVersion,
|
|
1208
|
+
change.version,
|
|
1209
|
+
winner
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
if (winner !== "remote") {
|
|
1213
|
+
return { applied: false, conflict: localVersion !== null, winner };
|
|
1214
|
+
}
|
|
1215
|
+
this.db.transaction(() => {
|
|
1216
|
+
this.applyData(change);
|
|
1217
|
+
this.db.prepare(
|
|
1218
|
+
`INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
|
|
1219
|
+
VALUES (?, ?, ?, ?, ?, 'remote', 1)`
|
|
1220
|
+
).run(
|
|
1221
|
+
change.collection,
|
|
1222
|
+
change._id,
|
|
1223
|
+
change.op,
|
|
1224
|
+
change.version,
|
|
1225
|
+
versionTs(change.version)
|
|
1226
|
+
);
|
|
1227
|
+
});
|
|
1228
|
+
return { applied: true, conflict: localVersion !== null, winner: "remote" };
|
|
1229
|
+
}
|
|
1230
|
+
applyData(change) {
|
|
1231
|
+
const { collection: coll, _id, op } = change;
|
|
1232
|
+
this.ensureCollTable(coll);
|
|
1233
|
+
if (op === "delete") {
|
|
1234
|
+
this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const doc = change.doc ?? {};
|
|
1238
|
+
const data = JSON.stringify(stripSystem2(doc));
|
|
1239
|
+
const ts = versionTs(change.version);
|
|
1240
|
+
const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
|
|
1241
|
+
this.db.prepare(
|
|
1242
|
+
`INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
|
|
1243
|
+
ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
|
|
1244
|
+
).run(_id, data, createdAt, ts);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Latest change per document with `seq` greater than the given watermark,
|
|
1248
|
+
* as RemoteChanges (used when this database acts as a sync *source*, e.g. the
|
|
1249
|
+
* monlite-as-remote adapter). Returns the new watermark to resume from.
|
|
1250
|
+
*/
|
|
1251
|
+
changesSince(seq, collections) {
|
|
1252
|
+
const params = [seq];
|
|
1253
|
+
let collFilter = "";
|
|
1254
|
+
if (collections && collections.length) {
|
|
1255
|
+
collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
|
|
1256
|
+
params.push(...collections);
|
|
1257
|
+
}
|
|
1258
|
+
const rows = this.db.prepare(
|
|
1259
|
+
`SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
|
|
1260
|
+
FROM _monlite_changes c
|
|
1261
|
+
JOIN (
|
|
1262
|
+
SELECT coll, doc_id, MAX(seq) AS mseq
|
|
1263
|
+
FROM _monlite_changes
|
|
1264
|
+
WHERE seq > ?${collFilter}
|
|
1265
|
+
GROUP BY coll, doc_id
|
|
1266
|
+
) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
|
|
1267
|
+
ORDER BY c.seq`
|
|
1268
|
+
).all(...params);
|
|
1269
|
+
const changes = rows.map((r) => {
|
|
1270
|
+
const change = {
|
|
1271
|
+
collection: r.coll,
|
|
1272
|
+
_id: r.doc_id,
|
|
1273
|
+
op: r.op,
|
|
1274
|
+
version: r.version
|
|
1275
|
+
};
|
|
1276
|
+
if (r.op === "upsert") {
|
|
1277
|
+
const doc = this.readDoc(r.coll, r.doc_id);
|
|
1278
|
+
if (doc) change.doc = doc;
|
|
1279
|
+
else change.op = "delete";
|
|
1280
|
+
}
|
|
1281
|
+
return change;
|
|
1282
|
+
});
|
|
1283
|
+
const maxRow = this.db.prepare(`SELECT MAX(seq) AS m FROM _monlite_changes`).get();
|
|
1284
|
+
return { changes, maxSeq: maxRow?.m ?? seq };
|
|
1285
|
+
}
|
|
1286
|
+
/* ------------------------------ bootstrap ----------------------------- */
|
|
1287
|
+
/**
|
|
1288
|
+
* Enqueue existing documents (created before sync was enabled, or never
|
|
1289
|
+
* recorded) as local upserts so they can be pushed. Idempotent.
|
|
1290
|
+
*/
|
|
1291
|
+
seed(collections) {
|
|
1292
|
+
let count = 0;
|
|
1293
|
+
this.db.transaction(() => {
|
|
1294
|
+
for (const coll of collections) {
|
|
1295
|
+
assertName(coll);
|
|
1296
|
+
const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
|
|
1297
|
+
for (const d of docs) {
|
|
1298
|
+
if (this.currentVersion(coll, d._id) !== null) continue;
|
|
1299
|
+
this.recordLocal(coll, d._id, "upsert", d.updated_at);
|
|
1300
|
+
count++;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
return count;
|
|
1305
|
+
}
|
|
1306
|
+
/* ------------------------------- state -------------------------------- */
|
|
1307
|
+
getState(remote) {
|
|
1308
|
+
const row = this.db.prepare(`SELECT * FROM _monlite_sync_state WHERE remote = ?`).get(remote);
|
|
1309
|
+
return {
|
|
1310
|
+
remote,
|
|
1311
|
+
cursor: row?.cursor ?? null,
|
|
1312
|
+
lastPullAt: row?.last_pull_at ?? null,
|
|
1313
|
+
lastPushSeq: row?.last_push_seq ?? null,
|
|
1314
|
+
lastPushAt: row?.last_push_at ?? null
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
setState(remote, patch) {
|
|
1318
|
+
const cur = this.getState(remote);
|
|
1319
|
+
const next = { ...cur, ...patch };
|
|
1320
|
+
this.db.prepare(
|
|
1321
|
+
`INSERT INTO _monlite_sync_state (remote, cursor, last_pull_at, last_push_seq, last_push_at)
|
|
1322
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1323
|
+
ON CONFLICT(remote) DO UPDATE SET
|
|
1324
|
+
cursor = excluded.cursor,
|
|
1325
|
+
last_pull_at = excluded.last_pull_at,
|
|
1326
|
+
last_push_seq = excluded.last_push_seq,
|
|
1327
|
+
last_push_at = excluded.last_push_at`
|
|
1328
|
+
).run(
|
|
1329
|
+
remote,
|
|
1330
|
+
next.cursor,
|
|
1331
|
+
next.lastPullAt,
|
|
1332
|
+
next.lastPushSeq,
|
|
1333
|
+
next.lastPushAt
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
/* ----------------------------- conflicts ------------------------------ */
|
|
1337
|
+
recordConflict(coll, id, localVersion, remoteVersion, winner) {
|
|
1338
|
+
this.db.prepare(
|
|
1339
|
+
`INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
|
|
1340
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
1341
|
+
).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
|
|
1342
|
+
}
|
|
1343
|
+
conflicts() {
|
|
1344
|
+
const rows = this.db.prepare(
|
|
1345
|
+
`SELECT coll, doc_id, local_version, remote_version, winner, ts
|
|
1346
|
+
FROM _monlite_conflicts ORDER BY id`
|
|
1347
|
+
).all();
|
|
1348
|
+
return rows.map((r) => ({
|
|
1349
|
+
collection: r.coll,
|
|
1350
|
+
_id: r.doc_id,
|
|
1351
|
+
localVersion: r.local_version,
|
|
1352
|
+
remoteVersion: r.remote_version,
|
|
1353
|
+
winner: r.winner,
|
|
1354
|
+
ts: r.ts
|
|
1355
|
+
}));
|
|
1356
|
+
}
|
|
1357
|
+
/* ------------------------------ helpers ------------------------------- */
|
|
1358
|
+
readDoc(coll, id) {
|
|
1359
|
+
assertName(coll);
|
|
1360
|
+
const row = this.db.prepare(
|
|
1361
|
+
`SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
|
|
1362
|
+
).get(id);
|
|
1363
|
+
if (!row) return null;
|
|
1364
|
+
const doc = JSON.parse(row.data);
|
|
1365
|
+
doc._id = row._id;
|
|
1366
|
+
doc.created_at = row.created_at;
|
|
1367
|
+
doc.updated_at = row.updated_at;
|
|
1368
|
+
return doc;
|
|
1369
|
+
}
|
|
1370
|
+
ensureCollTable(coll) {
|
|
1371
|
+
assertName(coll);
|
|
1372
|
+
this.db.exec(
|
|
1373
|
+
`CREATE TABLE IF NOT EXISTS "${coll}" (
|
|
1374
|
+
_id TEXT PRIMARY KEY,
|
|
1375
|
+
data TEXT NOT NULL,
|
|
1376
|
+
created_at INTEGER NOT NULL,
|
|
1377
|
+
updated_at INTEGER NOT NULL
|
|
1378
|
+
)`
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
|
|
801
1383
|
// src/db.ts
|
|
802
1384
|
function validateName(name) {
|
|
803
1385
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
@@ -823,6 +1405,8 @@ var Monlite = class {
|
|
|
823
1405
|
driver;
|
|
824
1406
|
/** @internal */
|
|
825
1407
|
autoIndexer;
|
|
1408
|
+
/** @internal Sync metadata store; present only when `{ sync: true }`. */
|
|
1409
|
+
$sync;
|
|
826
1410
|
collections = /* @__PURE__ */ new Map();
|
|
827
1411
|
closed = false;
|
|
828
1412
|
constructor(filename, options = {}) {
|
|
@@ -837,6 +1421,13 @@ var Monlite = class {
|
|
|
837
1421
|
options.autoIndex ?? true,
|
|
838
1422
|
options.autoIndexAfter ?? 10
|
|
839
1423
|
);
|
|
1424
|
+
if (options.sync) {
|
|
1425
|
+
this.$sync = new SyncStore(this.driver, options.nodeId);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
/** Stable node id for LWW tie-breaking (only when sync is enabled). */
|
|
1429
|
+
get nodeId() {
|
|
1430
|
+
return this.$sync?.nodeId;
|
|
840
1431
|
}
|
|
841
1432
|
/** The underlying native database handle (escape hatch). */
|
|
842
1433
|
get sqlite() {
|
|
@@ -846,17 +1437,35 @@ var Monlite = class {
|
|
|
846
1437
|
get driverName() {
|
|
847
1438
|
return this.driver.name;
|
|
848
1439
|
}
|
|
849
|
-
/**
|
|
850
|
-
|
|
1440
|
+
/**
|
|
1441
|
+
* Get (or lazily create) a typed collection handle. Pass `{ schema }` to make
|
|
1442
|
+
* it a structured collection backed by native SQL columns; omit for the
|
|
1443
|
+
* default schema-free document mode. Options apply only on first access.
|
|
1444
|
+
*/
|
|
1445
|
+
collection(name, options) {
|
|
851
1446
|
this.assertOpen();
|
|
852
1447
|
validateName(name);
|
|
853
1448
|
let col = this.collections.get(name);
|
|
854
1449
|
if (!col) {
|
|
855
|
-
col = new Collection(this, name);
|
|
1450
|
+
col = new Collection(this, name, options);
|
|
856
1451
|
this.collections.set(name, col);
|
|
857
1452
|
}
|
|
858
1453
|
return col;
|
|
859
1454
|
}
|
|
1455
|
+
/** Inspect a collection's physical columns (PRAGMA table_info). */
|
|
1456
|
+
$schema(name) {
|
|
1457
|
+
this.assertOpen();
|
|
1458
|
+
validateName(name);
|
|
1459
|
+
const rows = this.driver.prepare(`PRAGMA table_info("${name}")`).all();
|
|
1460
|
+
return Promise.resolve(
|
|
1461
|
+
rows.map((r) => ({
|
|
1462
|
+
name: r.name,
|
|
1463
|
+
type: r.type,
|
|
1464
|
+
notNull: !!r.notnull,
|
|
1465
|
+
primaryKey: !!r.pk
|
|
1466
|
+
}))
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
860
1469
|
/** Tagged-template SQL query returning rows. Values are safely parameterized. */
|
|
861
1470
|
$queryRaw(strings, ...values) {
|
|
862
1471
|
this.assertOpen();
|
|
@@ -896,7 +1505,9 @@ var Monlite = class {
|
|
|
896
1505
|
this.assertOpen();
|
|
897
1506
|
const rows = this.driver.prepare(
|
|
898
1507
|
`SELECT name FROM sqlite_master
|
|
899
|
-
WHERE type='table'
|
|
1508
|
+
WHERE type='table'
|
|
1509
|
+
AND name NOT LIKE 'sqlite_%'
|
|
1510
|
+
AND name NOT LIKE '\\_monlite\\_%' ESCAPE '\\'
|
|
900
1511
|
ORDER BY name`
|
|
901
1512
|
).all();
|
|
902
1513
|
return Promise.resolve(rows.map((r) => r.name));
|
|
@@ -934,8 +1545,12 @@ exports.Collection = Collection;
|
|
|
934
1545
|
exports.Monlite = Monlite;
|
|
935
1546
|
exports.MonliteError = MonliteError;
|
|
936
1547
|
exports.MonliteQueryError = MonliteQueryError;
|
|
1548
|
+
exports.SyncStore = SyncStore;
|
|
1549
|
+
exports.compareVersions = compareVersions;
|
|
937
1550
|
exports.createDb = createDb;
|
|
938
1551
|
exports.isObjectId = isObjectId;
|
|
1552
|
+
exports.makeVersion = makeVersion;
|
|
939
1553
|
exports.objectId = objectId;
|
|
1554
|
+
exports.versionTs = versionTs;
|
|
940
1555
|
//# sourceMappingURL=index.cjs.map
|
|
941
1556
|
//# sourceMappingURL=index.cjs.map
|