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