@monlite/core 0.3.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 +64 -0
- package/dist/index.cjs +637 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +198 -9
- package/dist/index.d.ts +198 -9
- package/dist/index.js +634 -83
- 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) ?? {};
|
|
@@ -403,7 +410,7 @@ function comparisonSql(expr, cmp2, params) {
|
|
|
403
410
|
}
|
|
404
411
|
return out;
|
|
405
412
|
}
|
|
406
|
-
function buildHaving(having, params) {
|
|
413
|
+
function buildHaving(having, params, columns) {
|
|
407
414
|
const parts = [];
|
|
408
415
|
if (having._count) {
|
|
409
416
|
parts.push(...comparisonSql("COUNT(*)", having._count, params));
|
|
@@ -412,7 +419,9 @@ function buildHaving(having, params) {
|
|
|
412
419
|
const selection = having[kind];
|
|
413
420
|
if (!selection) continue;
|
|
414
421
|
for (const field of Object.keys(selection)) {
|
|
415
|
-
parts.push(
|
|
422
|
+
parts.push(
|
|
423
|
+
...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
|
|
424
|
+
);
|
|
416
425
|
}
|
|
417
426
|
}
|
|
418
427
|
return parts.join(" AND ");
|
|
@@ -422,21 +431,25 @@ function groupBy(ctx, args) {
|
|
|
422
431
|
throw new Error("groupBy requires a non-empty `by` array");
|
|
423
432
|
}
|
|
424
433
|
const params = [];
|
|
425
|
-
const where = buildWhere(args.where, {
|
|
434
|
+
const where = buildWhere(args.where, {
|
|
435
|
+
params,
|
|
436
|
+
onPath: ctx.onPath,
|
|
437
|
+
columns: ctx.columns
|
|
438
|
+
});
|
|
426
439
|
const groupExprs = [];
|
|
427
440
|
const selects = [];
|
|
428
441
|
for (const field of args.by) {
|
|
429
|
-
ctx.onPath(field);
|
|
430
|
-
const expr = fieldExpr(field);
|
|
442
|
+
if (!isColumn(field, ctx.columns)) ctx.onPath(field);
|
|
443
|
+
const expr = fieldExpr(field, ctx.columns);
|
|
431
444
|
groupExprs.push(expr);
|
|
432
445
|
selects.push(`${expr} AS "${field}"`);
|
|
433
446
|
}
|
|
434
447
|
selects.push(`COUNT(*) AS agg_count`);
|
|
435
|
-
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath);
|
|
448
|
+
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
|
|
436
449
|
selects.push(...accSelects);
|
|
437
450
|
let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
|
|
438
451
|
if (args.having) {
|
|
439
|
-
const havingSql = buildHaving(args.having, params);
|
|
452
|
+
const havingSql = buildHaving(args.having, params, ctx.columns);
|
|
440
453
|
if (havingSql) sql += ` HAVING ${havingSql}`;
|
|
441
454
|
}
|
|
442
455
|
if (args.orderBy) {
|
|
@@ -444,7 +457,7 @@ function groupBy(ctx, args) {
|
|
|
444
457
|
for (const key of Object.keys(args.orderBy)) {
|
|
445
458
|
const dir = String(args.orderBy[key]).toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
446
459
|
if (key === "_count") parts.push(`agg_count ${dir}`);
|
|
447
|
-
else parts.push(`${fieldExpr(key)} ${dir}`);
|
|
460
|
+
else parts.push(`${fieldExpr(key, ctx.columns)} ${dir}`);
|
|
448
461
|
}
|
|
449
462
|
if (parts.length) sql += ` ORDER BY ${parts.join(", ")}`;
|
|
450
463
|
}
|
|
@@ -469,71 +482,216 @@ function groupBy(ctx, args) {
|
|
|
469
482
|
}
|
|
470
483
|
|
|
471
484
|
// src/collection.ts
|
|
472
|
-
var SELECT_COLS = `_id, data, created_at, updated_at`;
|
|
473
485
|
function stripSystem(obj) {
|
|
474
486
|
const { _id, created_at, updated_at, ...rest } = obj;
|
|
475
487
|
return rest;
|
|
476
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
|
+
}
|
|
477
498
|
var Collection = class {
|
|
478
|
-
constructor(mon, name) {
|
|
499
|
+
constructor(mon, name, options = {}) {
|
|
479
500
|
this.mon = mon;
|
|
480
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
|
+
}
|
|
481
520
|
}
|
|
482
521
|
mon;
|
|
483
522
|
name;
|
|
523
|
+
mode;
|
|
484
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;
|
|
485
531
|
trackPath = (path) => this.mon.autoIndexer.track(this.name, path);
|
|
486
532
|
get db() {
|
|
487
533
|
return this.mon.driver;
|
|
488
534
|
}
|
|
535
|
+
/** Native column names declared for this collection (structured mode). */
|
|
536
|
+
get columnNames() {
|
|
537
|
+
return [...this.columnOrder];
|
|
538
|
+
}
|
|
489
539
|
ensureTable() {
|
|
490
540
|
if (this.initialized) return;
|
|
491
|
-
this.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
}
|
|
499
579
|
this.initialized = true;
|
|
500
580
|
}
|
|
581
|
+
/* --------------------------- row <-> doc -------------------------- */
|
|
501
582
|
rowToDoc(row) {
|
|
502
|
-
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
|
+
}
|
|
503
591
|
doc._id = row._id;
|
|
504
592
|
doc.created_at = row.created_at;
|
|
505
593
|
doc.updated_at = row.updated_at;
|
|
506
594
|
return doc;
|
|
507
595
|
}
|
|
508
|
-
|
|
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) {
|
|
509
614
|
const now = Date.now();
|
|
510
615
|
const id = input._id != null ? String(input._id) : objectId();
|
|
511
616
|
const doc = stripSystem(input);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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;
|
|
518
672
|
}
|
|
519
673
|
/* ----------------------------- create ----------------------------- */
|
|
520
674
|
async create(args) {
|
|
521
675
|
this.ensureTable();
|
|
522
|
-
const row = this.
|
|
523
|
-
this.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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;
|
|
527
685
|
}
|
|
528
686
|
async createMany(args) {
|
|
529
687
|
this.ensureTable();
|
|
530
|
-
const stmt = this.db.prepare(
|
|
531
|
-
|
|
532
|
-
);
|
|
688
|
+
const stmt = this.db.prepare(this.insertSql());
|
|
689
|
+
const recorder = this.recorder;
|
|
533
690
|
this.db.transaction(() => {
|
|
534
691
|
for (const item of args.data) {
|
|
535
|
-
const row = this.
|
|
536
|
-
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);
|
|
537
695
|
}
|
|
538
696
|
});
|
|
539
697
|
return { count: args.data.length };
|
|
@@ -542,9 +700,13 @@ var Collection = class {
|
|
|
542
700
|
async findMany(args = {}) {
|
|
543
701
|
this.ensureTable();
|
|
544
702
|
const params = [];
|
|
545
|
-
const where = buildWhere(args.where, {
|
|
546
|
-
|
|
547
|
-
|
|
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);
|
|
548
710
|
if (order) sql += " " + order;
|
|
549
711
|
if (args.take != null) {
|
|
550
712
|
sql += " LIMIT ?";
|
|
@@ -555,9 +717,7 @@ var Collection = class {
|
|
|
555
717
|
params.push(args.skip);
|
|
556
718
|
}
|
|
557
719
|
const rows = this.db.prepare(sql).all(...params);
|
|
558
|
-
return rows.map(
|
|
559
|
-
(r) => project(this.rowToDoc(r), args.select)
|
|
560
|
-
);
|
|
720
|
+
return rows.map((r) => project(this.rowToDoc(r), args.select));
|
|
561
721
|
}
|
|
562
722
|
async findFirst(args = {}) {
|
|
563
723
|
const rows = await this.findMany({ ...args, take: 1 });
|
|
@@ -565,27 +725,35 @@ var Collection = class {
|
|
|
565
725
|
}
|
|
566
726
|
async findById(id) {
|
|
567
727
|
this.ensureTable();
|
|
568
|
-
const row = this.db.prepare(`SELECT
|
|
728
|
+
const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
|
|
569
729
|
return row ? this.rowToDoc(row) : null;
|
|
570
730
|
}
|
|
571
731
|
async count(args = {}) {
|
|
572
732
|
this.ensureTable();
|
|
573
733
|
const params = [];
|
|
574
|
-
const where = buildWhere(args.where, {
|
|
734
|
+
const where = buildWhere(args.where, {
|
|
735
|
+
params,
|
|
736
|
+
onPath: this.trackPath,
|
|
737
|
+
columns: this.columns
|
|
738
|
+
});
|
|
575
739
|
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM "${this.name}" WHERE ${where}`).get(...params);
|
|
576
740
|
return row.n;
|
|
577
741
|
}
|
|
578
742
|
/**
|
|
579
|
-
* Return the distinct values of a field
|
|
580
|
-
*
|
|
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`.
|
|
581
745
|
*/
|
|
582
746
|
async distinct(field, where) {
|
|
583
747
|
this.ensureTable();
|
|
584
748
|
const params = [];
|
|
585
|
-
const clause = buildWhere(where, {
|
|
749
|
+
const clause = buildWhere(where, {
|
|
750
|
+
params,
|
|
751
|
+
onPath: this.trackPath,
|
|
752
|
+
columns: this.columns
|
|
753
|
+
});
|
|
586
754
|
let sql;
|
|
587
|
-
if (
|
|
588
|
-
sql = `SELECT DISTINCT ${fieldExpr(field)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
|
|
755
|
+
if (isColumn(field, this.columns)) {
|
|
756
|
+
sql = `SELECT DISTINCT ${fieldExpr(field, this.columns)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
|
|
589
757
|
} else {
|
|
590
758
|
this.trackPath(field);
|
|
591
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`;
|
|
@@ -597,21 +765,25 @@ var Collection = class {
|
|
|
597
765
|
runUpdate(where, data, single) {
|
|
598
766
|
this.ensureTable();
|
|
599
767
|
const params = [];
|
|
600
|
-
const clause = buildWhere(where, {
|
|
601
|
-
|
|
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}`;
|
|
602
774
|
if (single) selectSql += " LIMIT 1";
|
|
603
775
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
604
776
|
if (!rows.length) return [];
|
|
605
777
|
const now = Date.now();
|
|
606
|
-
const
|
|
607
|
-
`UPDATE "${this.name}" SET data = ?, updated_at = ? WHERE _id = ?`
|
|
608
|
-
);
|
|
778
|
+
const recorder = this.recorder;
|
|
609
779
|
return this.db.transaction(() => {
|
|
610
780
|
const out = [];
|
|
611
781
|
for (const row of rows) {
|
|
612
|
-
const current =
|
|
782
|
+
const current = stripSystem(this.rowToDoc(row));
|
|
613
783
|
const updated = stripSystem(applyUpdate(current, data));
|
|
614
|
-
|
|
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);
|
|
615
787
|
out.push({
|
|
616
788
|
...updated,
|
|
617
789
|
_id: row._id,
|
|
@@ -644,14 +816,23 @@ var Collection = class {
|
|
|
644
816
|
runDelete(where, single) {
|
|
645
817
|
this.ensureTable();
|
|
646
818
|
const params = [];
|
|
647
|
-
const clause = buildWhere(where, {
|
|
648
|
-
|
|
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}`;
|
|
649
825
|
if (single) selectSql += " LIMIT 1";
|
|
650
826
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
651
827
|
if (!rows.length) return [];
|
|
652
828
|
const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
|
|
829
|
+
const recorder = this.recorder;
|
|
830
|
+
const now = Date.now();
|
|
653
831
|
this.db.transaction(() => {
|
|
654
|
-
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
|
+
}
|
|
655
836
|
});
|
|
656
837
|
return rows.map((r) => this.rowToDoc(r));
|
|
657
838
|
}
|
|
@@ -665,14 +846,14 @@ var Collection = class {
|
|
|
665
846
|
async aggregate(args = {}) {
|
|
666
847
|
this.ensureTable();
|
|
667
848
|
return aggregate(
|
|
668
|
-
{ db: this.db, table: this.name, onPath: this.trackPath },
|
|
849
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
669
850
|
args
|
|
670
851
|
);
|
|
671
852
|
}
|
|
672
853
|
async groupBy(args) {
|
|
673
854
|
this.ensureTable();
|
|
674
855
|
return groupBy(
|
|
675
|
-
{ db: this.db, table: this.name, onPath: this.trackPath },
|
|
856
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
676
857
|
args
|
|
677
858
|
);
|
|
678
859
|
}
|
|
@@ -858,6 +1039,347 @@ function createDriver(filename, options = {}) {
|
|
|
858
1039
|
);
|
|
859
1040
|
}
|
|
860
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
|
+
|
|
861
1383
|
// src/db.ts
|
|
862
1384
|
function validateName(name) {
|
|
863
1385
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
@@ -883,6 +1405,8 @@ var Monlite = class {
|
|
|
883
1405
|
driver;
|
|
884
1406
|
/** @internal */
|
|
885
1407
|
autoIndexer;
|
|
1408
|
+
/** @internal Sync metadata store; present only when `{ sync: true }`. */
|
|
1409
|
+
$sync;
|
|
886
1410
|
collections = /* @__PURE__ */ new Map();
|
|
887
1411
|
closed = false;
|
|
888
1412
|
constructor(filename, options = {}) {
|
|
@@ -897,6 +1421,13 @@ var Monlite = class {
|
|
|
897
1421
|
options.autoIndex ?? true,
|
|
898
1422
|
options.autoIndexAfter ?? 10
|
|
899
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;
|
|
900
1431
|
}
|
|
901
1432
|
/** The underlying native database handle (escape hatch). */
|
|
902
1433
|
get sqlite() {
|
|
@@ -906,17 +1437,35 @@ var Monlite = class {
|
|
|
906
1437
|
get driverName() {
|
|
907
1438
|
return this.driver.name;
|
|
908
1439
|
}
|
|
909
|
-
/**
|
|
910
|
-
|
|
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) {
|
|
911
1446
|
this.assertOpen();
|
|
912
1447
|
validateName(name);
|
|
913
1448
|
let col = this.collections.get(name);
|
|
914
1449
|
if (!col) {
|
|
915
|
-
col = new Collection(this, name);
|
|
1450
|
+
col = new Collection(this, name, options);
|
|
916
1451
|
this.collections.set(name, col);
|
|
917
1452
|
}
|
|
918
1453
|
return col;
|
|
919
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
|
+
}
|
|
920
1469
|
/** Tagged-template SQL query returning rows. Values are safely parameterized. */
|
|
921
1470
|
$queryRaw(strings, ...values) {
|
|
922
1471
|
this.assertOpen();
|
|
@@ -956,7 +1505,9 @@ var Monlite = class {
|
|
|
956
1505
|
this.assertOpen();
|
|
957
1506
|
const rows = this.driver.prepare(
|
|
958
1507
|
`SELECT name FROM sqlite_master
|
|
959
|
-
WHERE type='table'
|
|
1508
|
+
WHERE type='table'
|
|
1509
|
+
AND name NOT LIKE 'sqlite_%'
|
|
1510
|
+
AND name NOT LIKE '\\_monlite\\_%' ESCAPE '\\'
|
|
960
1511
|
ORDER BY name`
|
|
961
1512
|
).all();
|
|
962
1513
|
return Promise.resolve(rows.map((r) => r.name));
|
|
@@ -994,8 +1545,12 @@ exports.Collection = Collection;
|
|
|
994
1545
|
exports.Monlite = Monlite;
|
|
995
1546
|
exports.MonliteError = MonliteError;
|
|
996
1547
|
exports.MonliteQueryError = MonliteQueryError;
|
|
1548
|
+
exports.SyncStore = SyncStore;
|
|
1549
|
+
exports.compareVersions = compareVersions;
|
|
997
1550
|
exports.createDb = createDb;
|
|
998
1551
|
exports.isObjectId = isObjectId;
|
|
1552
|
+
exports.makeVersion = makeVersion;
|
|
999
1553
|
exports.objectId = objectId;
|
|
1554
|
+
exports.versionTs = versionTs;
|
|
1000
1555
|
//# sourceMappingURL=index.cjs.map
|
|
1001
1556
|
//# sourceMappingURL=index.cjs.map
|