@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.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) ?? {};
|
|
@@ -376,30 +383,78 @@ function aggregate(ctx, args) {
|
|
|
376
383
|
}
|
|
377
384
|
return result;
|
|
378
385
|
}
|
|
386
|
+
var HAVING_FNS = [
|
|
387
|
+
["_sum", "SUM"],
|
|
388
|
+
["_avg", "AVG"],
|
|
389
|
+
["_min", "MIN"],
|
|
390
|
+
["_max", "MAX"]
|
|
391
|
+
];
|
|
392
|
+
function comparisonSql(expr, cmp2, params) {
|
|
393
|
+
const out = [];
|
|
394
|
+
const ops = [
|
|
395
|
+
["equals", "="],
|
|
396
|
+
["not", "<>"],
|
|
397
|
+
["gt", ">"],
|
|
398
|
+
["gte", ">="],
|
|
399
|
+
["lt", "<"],
|
|
400
|
+
["lte", "<="]
|
|
401
|
+
];
|
|
402
|
+
for (const [key, op] of ops) {
|
|
403
|
+
const v = cmp2[key];
|
|
404
|
+
if (v === void 0) continue;
|
|
405
|
+
params.push(v);
|
|
406
|
+
out.push(`${expr} ${op} ?`);
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
function buildHaving(having, params, columns) {
|
|
411
|
+
const parts = [];
|
|
412
|
+
if (having._count) {
|
|
413
|
+
parts.push(...comparisonSql("COUNT(*)", having._count, params));
|
|
414
|
+
}
|
|
415
|
+
for (const [kind, fn] of HAVING_FNS) {
|
|
416
|
+
const selection = having[kind];
|
|
417
|
+
if (!selection) continue;
|
|
418
|
+
for (const field of Object.keys(selection)) {
|
|
419
|
+
parts.push(
|
|
420
|
+
...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return parts.join(" AND ");
|
|
425
|
+
}
|
|
379
426
|
function groupBy(ctx, args) {
|
|
380
427
|
if (!Array.isArray(args.by) || args.by.length === 0) {
|
|
381
428
|
throw new Error("groupBy requires a non-empty `by` array");
|
|
382
429
|
}
|
|
383
430
|
const params = [];
|
|
384
|
-
const where = buildWhere(args.where, {
|
|
431
|
+
const where = buildWhere(args.where, {
|
|
432
|
+
params,
|
|
433
|
+
onPath: ctx.onPath,
|
|
434
|
+
columns: ctx.columns
|
|
435
|
+
});
|
|
385
436
|
const groupExprs = [];
|
|
386
437
|
const selects = [];
|
|
387
438
|
for (const field of args.by) {
|
|
388
|
-
ctx.onPath(field);
|
|
389
|
-
const expr = fieldExpr(field);
|
|
439
|
+
if (!isColumn(field, ctx.columns)) ctx.onPath(field);
|
|
440
|
+
const expr = fieldExpr(field, ctx.columns);
|
|
390
441
|
groupExprs.push(expr);
|
|
391
442
|
selects.push(`${expr} AS "${field}"`);
|
|
392
443
|
}
|
|
393
444
|
selects.push(`COUNT(*) AS agg_count`);
|
|
394
|
-
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath);
|
|
445
|
+
const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
|
|
395
446
|
selects.push(...accSelects);
|
|
396
447
|
let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
|
|
448
|
+
if (args.having) {
|
|
449
|
+
const havingSql = buildHaving(args.having, params, ctx.columns);
|
|
450
|
+
if (havingSql) sql += ` HAVING ${havingSql}`;
|
|
451
|
+
}
|
|
397
452
|
if (args.orderBy) {
|
|
398
453
|
const parts = [];
|
|
399
454
|
for (const key of Object.keys(args.orderBy)) {
|
|
400
455
|
const dir = String(args.orderBy[key]).toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
401
456
|
if (key === "_count") parts.push(`agg_count ${dir}`);
|
|
402
|
-
else parts.push(`${fieldExpr(key)} ${dir}`);
|
|
457
|
+
else parts.push(`${fieldExpr(key, ctx.columns)} ${dir}`);
|
|
403
458
|
}
|
|
404
459
|
if (parts.length) sql += ` ORDER BY ${parts.join(", ")}`;
|
|
405
460
|
}
|
|
@@ -424,71 +479,216 @@ function groupBy(ctx, args) {
|
|
|
424
479
|
}
|
|
425
480
|
|
|
426
481
|
// src/collection.ts
|
|
427
|
-
var SELECT_COLS = `_id, data, created_at, updated_at`;
|
|
428
482
|
function stripSystem(obj) {
|
|
429
483
|
const { _id, created_at, updated_at, ...rest } = obj;
|
|
430
484
|
return rest;
|
|
431
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
|
+
}
|
|
432
495
|
var Collection = class {
|
|
433
|
-
constructor(mon, name) {
|
|
496
|
+
constructor(mon, name, options = {}) {
|
|
434
497
|
this.mon = mon;
|
|
435
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
|
+
}
|
|
436
517
|
}
|
|
437
518
|
mon;
|
|
438
519
|
name;
|
|
520
|
+
mode;
|
|
439
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;
|
|
440
528
|
trackPath = (path) => this.mon.autoIndexer.track(this.name, path);
|
|
441
529
|
get db() {
|
|
442
530
|
return this.mon.driver;
|
|
443
531
|
}
|
|
532
|
+
/** Native column names declared for this collection (structured mode). */
|
|
533
|
+
get columnNames() {
|
|
534
|
+
return [...this.columnOrder];
|
|
535
|
+
}
|
|
444
536
|
ensureTable() {
|
|
445
537
|
if (this.initialized) return;
|
|
446
|
-
this.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
}
|
|
454
576
|
this.initialized = true;
|
|
455
577
|
}
|
|
578
|
+
/* --------------------------- row <-> doc -------------------------- */
|
|
456
579
|
rowToDoc(row) {
|
|
457
|
-
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
|
+
}
|
|
458
588
|
doc._id = row._id;
|
|
459
589
|
doc.created_at = row.created_at;
|
|
460
590
|
doc.updated_at = row.updated_at;
|
|
461
591
|
return doc;
|
|
462
592
|
}
|
|
463
|
-
|
|
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) {
|
|
464
611
|
const now = Date.now();
|
|
465
612
|
const id = input._id != null ? String(input._id) : objectId();
|
|
466
613
|
const doc = stripSystem(input);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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;
|
|
473
669
|
}
|
|
474
670
|
/* ----------------------------- create ----------------------------- */
|
|
475
671
|
async create(args) {
|
|
476
672
|
this.ensureTable();
|
|
477
|
-
const row = this.
|
|
478
|
-
this.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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;
|
|
482
682
|
}
|
|
483
683
|
async createMany(args) {
|
|
484
684
|
this.ensureTable();
|
|
485
|
-
const stmt = this.db.prepare(
|
|
486
|
-
|
|
487
|
-
);
|
|
685
|
+
const stmt = this.db.prepare(this.insertSql());
|
|
686
|
+
const recorder = this.recorder;
|
|
488
687
|
this.db.transaction(() => {
|
|
489
688
|
for (const item of args.data) {
|
|
490
|
-
const row = this.
|
|
491
|
-
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);
|
|
492
692
|
}
|
|
493
693
|
});
|
|
494
694
|
return { count: args.data.length };
|
|
@@ -497,9 +697,13 @@ var Collection = class {
|
|
|
497
697
|
async findMany(args = {}) {
|
|
498
698
|
this.ensureTable();
|
|
499
699
|
const params = [];
|
|
500
|
-
const where = buildWhere(args.where, {
|
|
501
|
-
|
|
502
|
-
|
|
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);
|
|
503
707
|
if (order) sql += " " + order;
|
|
504
708
|
if (args.take != null) {
|
|
505
709
|
sql += " LIMIT ?";
|
|
@@ -510,9 +714,7 @@ var Collection = class {
|
|
|
510
714
|
params.push(args.skip);
|
|
511
715
|
}
|
|
512
716
|
const rows = this.db.prepare(sql).all(...params);
|
|
513
|
-
return rows.map(
|
|
514
|
-
(r) => project(this.rowToDoc(r), args.select)
|
|
515
|
-
);
|
|
717
|
+
return rows.map((r) => project(this.rowToDoc(r), args.select));
|
|
516
718
|
}
|
|
517
719
|
async findFirst(args = {}) {
|
|
518
720
|
const rows = await this.findMany({ ...args, take: 1 });
|
|
@@ -520,35 +722,65 @@ var Collection = class {
|
|
|
520
722
|
}
|
|
521
723
|
async findById(id) {
|
|
522
724
|
this.ensureTable();
|
|
523
|
-
const row = this.db.prepare(`SELECT
|
|
725
|
+
const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
|
|
524
726
|
return row ? this.rowToDoc(row) : null;
|
|
525
727
|
}
|
|
526
728
|
async count(args = {}) {
|
|
527
729
|
this.ensureTable();
|
|
528
730
|
const params = [];
|
|
529
|
-
const where = buildWhere(args.where, {
|
|
731
|
+
const where = buildWhere(args.where, {
|
|
732
|
+
params,
|
|
733
|
+
onPath: this.trackPath,
|
|
734
|
+
columns: this.columns
|
|
735
|
+
});
|
|
530
736
|
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM "${this.name}" WHERE ${where}`).get(...params);
|
|
531
737
|
return row.n;
|
|
532
738
|
}
|
|
739
|
+
/**
|
|
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`.
|
|
742
|
+
*/
|
|
743
|
+
async distinct(field, where) {
|
|
744
|
+
this.ensureTable();
|
|
745
|
+
const params = [];
|
|
746
|
+
const clause = buildWhere(where, {
|
|
747
|
+
params,
|
|
748
|
+
onPath: this.trackPath,
|
|
749
|
+
columns: this.columns
|
|
750
|
+
});
|
|
751
|
+
let sql;
|
|
752
|
+
if (isColumn(field, this.columns)) {
|
|
753
|
+
sql = `SELECT DISTINCT ${fieldExpr(field, this.columns)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
|
|
754
|
+
} else {
|
|
755
|
+
this.trackPath(field);
|
|
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`;
|
|
757
|
+
}
|
|
758
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
759
|
+
return rows.map((r) => r.v);
|
|
760
|
+
}
|
|
533
761
|
/* ----------------------------- update ----------------------------- */
|
|
534
762
|
runUpdate(where, data, single) {
|
|
535
763
|
this.ensureTable();
|
|
536
764
|
const params = [];
|
|
537
|
-
const clause = buildWhere(where, {
|
|
538
|
-
|
|
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}`;
|
|
539
771
|
if (single) selectSql += " LIMIT 1";
|
|
540
772
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
541
773
|
if (!rows.length) return [];
|
|
542
774
|
const now = Date.now();
|
|
543
|
-
const
|
|
544
|
-
`UPDATE "${this.name}" SET data = ?, updated_at = ? WHERE _id = ?`
|
|
545
|
-
);
|
|
775
|
+
const recorder = this.recorder;
|
|
546
776
|
return this.db.transaction(() => {
|
|
547
777
|
const out = [];
|
|
548
778
|
for (const row of rows) {
|
|
549
|
-
const current =
|
|
779
|
+
const current = stripSystem(this.rowToDoc(row));
|
|
550
780
|
const updated = stripSystem(applyUpdate(current, data));
|
|
551
|
-
|
|
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);
|
|
552
784
|
out.push({
|
|
553
785
|
...updated,
|
|
554
786
|
_id: row._id,
|
|
@@ -581,14 +813,23 @@ var Collection = class {
|
|
|
581
813
|
runDelete(where, single) {
|
|
582
814
|
this.ensureTable();
|
|
583
815
|
const params = [];
|
|
584
|
-
const clause = buildWhere(where, {
|
|
585
|
-
|
|
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}`;
|
|
586
822
|
if (single) selectSql += " LIMIT 1";
|
|
587
823
|
const rows = this.db.prepare(selectSql).all(...params);
|
|
588
824
|
if (!rows.length) return [];
|
|
589
825
|
const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
|
|
826
|
+
const recorder = this.recorder;
|
|
827
|
+
const now = Date.now();
|
|
590
828
|
this.db.transaction(() => {
|
|
591
|
-
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
|
+
}
|
|
592
833
|
});
|
|
593
834
|
return rows.map((r) => this.rowToDoc(r));
|
|
594
835
|
}
|
|
@@ -602,14 +843,14 @@ var Collection = class {
|
|
|
602
843
|
async aggregate(args = {}) {
|
|
603
844
|
this.ensureTable();
|
|
604
845
|
return aggregate(
|
|
605
|
-
{ db: this.db, table: this.name, onPath: this.trackPath },
|
|
846
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
606
847
|
args
|
|
607
848
|
);
|
|
608
849
|
}
|
|
609
850
|
async groupBy(args) {
|
|
610
851
|
this.ensureTable();
|
|
611
852
|
return groupBy(
|
|
612
|
-
{ db: this.db, table: this.name, onPath: this.trackPath },
|
|
853
|
+
{ db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
|
|
613
854
|
args
|
|
614
855
|
);
|
|
615
856
|
}
|
|
@@ -795,6 +1036,347 @@ function createDriver(filename, options = {}) {
|
|
|
795
1036
|
);
|
|
796
1037
|
}
|
|
797
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
|
+
|
|
798
1380
|
// src/db.ts
|
|
799
1381
|
function validateName(name) {
|
|
800
1382
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
@@ -820,6 +1402,8 @@ var Monlite = class {
|
|
|
820
1402
|
driver;
|
|
821
1403
|
/** @internal */
|
|
822
1404
|
autoIndexer;
|
|
1405
|
+
/** @internal Sync metadata store; present only when `{ sync: true }`. */
|
|
1406
|
+
$sync;
|
|
823
1407
|
collections = /* @__PURE__ */ new Map();
|
|
824
1408
|
closed = false;
|
|
825
1409
|
constructor(filename, options = {}) {
|
|
@@ -834,6 +1418,13 @@ var Monlite = class {
|
|
|
834
1418
|
options.autoIndex ?? true,
|
|
835
1419
|
options.autoIndexAfter ?? 10
|
|
836
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;
|
|
837
1428
|
}
|
|
838
1429
|
/** The underlying native database handle (escape hatch). */
|
|
839
1430
|
get sqlite() {
|
|
@@ -843,17 +1434,35 @@ var Monlite = class {
|
|
|
843
1434
|
get driverName() {
|
|
844
1435
|
return this.driver.name;
|
|
845
1436
|
}
|
|
846
|
-
/**
|
|
847
|
-
|
|
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) {
|
|
848
1443
|
this.assertOpen();
|
|
849
1444
|
validateName(name);
|
|
850
1445
|
let col = this.collections.get(name);
|
|
851
1446
|
if (!col) {
|
|
852
|
-
col = new Collection(this, name);
|
|
1447
|
+
col = new Collection(this, name, options);
|
|
853
1448
|
this.collections.set(name, col);
|
|
854
1449
|
}
|
|
855
1450
|
return col;
|
|
856
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
|
+
}
|
|
857
1466
|
/** Tagged-template SQL query returning rows. Values are safely parameterized. */
|
|
858
1467
|
$queryRaw(strings, ...values) {
|
|
859
1468
|
this.assertOpen();
|
|
@@ -893,7 +1502,9 @@ var Monlite = class {
|
|
|
893
1502
|
this.assertOpen();
|
|
894
1503
|
const rows = this.driver.prepare(
|
|
895
1504
|
`SELECT name FROM sqlite_master
|
|
896
|
-
WHERE type='table'
|
|
1505
|
+
WHERE type='table'
|
|
1506
|
+
AND name NOT LIKE 'sqlite_%'
|
|
1507
|
+
AND name NOT LIKE '\\_monlite\\_%' ESCAPE '\\'
|
|
897
1508
|
ORDER BY name`
|
|
898
1509
|
).all();
|
|
899
1510
|
return Promise.resolve(rows.map((r) => r.name));
|
|
@@ -927,6 +1538,6 @@ function createDb(filename, options) {
|
|
|
927
1538
|
return new Monlite(filename, options);
|
|
928
1539
|
}
|
|
929
1540
|
|
|
930
|
-
export { Collection, Monlite, MonliteError, MonliteQueryError, createDb, isObjectId, objectId };
|
|
1541
|
+
export { Collection, Monlite, MonliteError, MonliteQueryError, SyncStore, compareVersions, createDb, isObjectId, makeVersion, objectId, versionTs };
|
|
931
1542
|
//# sourceMappingURL=index.js.map
|
|
932
1543
|
//# sourceMappingURL=index.js.map
|