@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/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 (isReserved(field)) return `"${field}"`;
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 && !isReserved(field)) ctx.onPath(field);
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 (isReserved(field)) {
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 (isReserved(field)) return `${expr} = ?`;
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 (isReserved(field)) {
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 && !isReserved(field)) onPath(field);
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, { params, onPath: ctx.onPath });
370
- const { selects, cols } = buildAccumulators(args, ctx.onPath);
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(...comparisonSql(`${fn}(${fieldExpr(field)})`, selection[field], params));
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, { params, onPath: ctx.onPath });
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.db.exec(
492
- `CREATE TABLE IF NOT EXISTS "${this.name}" (
493
- _id TEXT PRIMARY KEY,
494
- data TEXT NOT NULL,
495
- created_at INTEGER NOT NULL,
496
- updated_at INTEGER NOT NULL
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
- prepareInsert(input) {
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
- return {
513
- _id: id,
514
- data: JSON.stringify(doc),
515
- created_at: now,
516
- updated_at: now
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.prepareInsert(args.data);
523
- this.db.prepare(
524
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
525
- ).run(row._id, row.data, row.created_at, row.updated_at);
526
- return this.rowToDoc(row);
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
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
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.prepareInsert(item);
536
- stmt.run(row._id, row.data, row.created_at, row.updated_at);
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, { params, onPath: this.trackPath });
546
- let sql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${where}`;
547
- const order = buildOrderBy(args.orderBy, this.trackPath);
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 ${SELECT_COLS} FROM "${this.name}" WHERE _id = ?`).get(id);
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, { params, onPath: this.trackPath });
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 across the collection. Array fields
580
- * are unwound (each element counts as a value), matching MongoDB's `distinct`.
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, { params, onPath: this.trackPath });
749
+ const clause = buildWhere(where, {
750
+ params,
751
+ onPath: this.trackPath,
752
+ columns: this.columns
753
+ });
586
754
  let sql;
587
- if (isReserved(field)) {
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, { params, onPath: this.trackPath });
601
- let selectSql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${clause}`;
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 stmt = this.db.prepare(
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 = JSON.parse(row.data);
782
+ const current = stripSystem(this.rowToDoc(row));
613
783
  const updated = stripSystem(applyUpdate(current, data));
614
- stmt.run(JSON.stringify(updated), now, row._id);
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, { params, onPath: this.trackPath });
648
- let selectSql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${clause}`;
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) stmt.run(row._id);
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
- /** Get (or lazily create) a typed collection handle. */
910
- collection(name) {
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' AND name NOT LIKE 'sqlite_%'
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