@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.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 (isReserved(field)) return `"${field}"`;
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 && !isReserved(field)) ctx.onPath(field);
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 (isReserved(field)) {
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 (isReserved(field)) return `${expr} = ?`;
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 (isReserved(field)) {
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 && !isReserved(field)) onPath(field);
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, { params, onPath: ctx.onPath });
367
- const { selects, cols } = buildAccumulators(args, ctx.onPath);
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(...comparisonSql(`${fn}(${fieldExpr(field)})`, selection[field], params));
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, { params, onPath: ctx.onPath });
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.db.exec(
489
- `CREATE TABLE IF NOT EXISTS "${this.name}" (
490
- _id TEXT PRIMARY KEY,
491
- data TEXT NOT NULL,
492
- created_at INTEGER NOT NULL,
493
- updated_at INTEGER NOT NULL
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
- prepareInsert(input) {
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
- return {
510
- _id: id,
511
- data: JSON.stringify(doc),
512
- created_at: now,
513
- updated_at: now
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.prepareInsert(args.data);
520
- this.db.prepare(
521
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
522
- ).run(row._id, row.data, row.created_at, row.updated_at);
523
- return this.rowToDoc(row);
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
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
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.prepareInsert(item);
533
- stmt.run(row._id, row.data, row.created_at, row.updated_at);
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, { params, onPath: this.trackPath });
543
- let sql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${where}`;
544
- const order = buildOrderBy(args.orderBy, this.trackPath);
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 ${SELECT_COLS} FROM "${this.name}" WHERE _id = ?`).get(id);
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, { params, onPath: this.trackPath });
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 across the collection. Array fields
577
- * are unwound (each element counts as a value), matching MongoDB's `distinct`.
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, { params, onPath: this.trackPath });
746
+ const clause = buildWhere(where, {
747
+ params,
748
+ onPath: this.trackPath,
749
+ columns: this.columns
750
+ });
583
751
  let sql;
584
- if (isReserved(field)) {
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, { params, onPath: this.trackPath });
598
- let selectSql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${clause}`;
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 stmt = this.db.prepare(
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 = JSON.parse(row.data);
779
+ const current = stripSystem(this.rowToDoc(row));
610
780
  const updated = stripSystem(applyUpdate(current, data));
611
- stmt.run(JSON.stringify(updated), now, row._id);
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, { params, onPath: this.trackPath });
645
- let selectSql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${clause}`;
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) stmt.run(row._id);
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
- /** Get (or lazily create) a typed collection handle. */
907
- collection(name) {
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' AND name NOT LIKE 'sqlite_%'
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