@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/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) ?? {};
@@ -379,30 +386,78 @@ function aggregate(ctx, args) {
379
386
  }
380
387
  return result;
381
388
  }
389
+ var HAVING_FNS = [
390
+ ["_sum", "SUM"],
391
+ ["_avg", "AVG"],
392
+ ["_min", "MIN"],
393
+ ["_max", "MAX"]
394
+ ];
395
+ function comparisonSql(expr, cmp2, params) {
396
+ const out = [];
397
+ const ops = [
398
+ ["equals", "="],
399
+ ["not", "<>"],
400
+ ["gt", ">"],
401
+ ["gte", ">="],
402
+ ["lt", "<"],
403
+ ["lte", "<="]
404
+ ];
405
+ for (const [key, op] of ops) {
406
+ const v = cmp2[key];
407
+ if (v === void 0) continue;
408
+ params.push(v);
409
+ out.push(`${expr} ${op} ?`);
410
+ }
411
+ return out;
412
+ }
413
+ function buildHaving(having, params, columns) {
414
+ const parts = [];
415
+ if (having._count) {
416
+ parts.push(...comparisonSql("COUNT(*)", having._count, params));
417
+ }
418
+ for (const [kind, fn] of HAVING_FNS) {
419
+ const selection = having[kind];
420
+ if (!selection) continue;
421
+ for (const field of Object.keys(selection)) {
422
+ parts.push(
423
+ ...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
424
+ );
425
+ }
426
+ }
427
+ return parts.join(" AND ");
428
+ }
382
429
  function groupBy(ctx, args) {
383
430
  if (!Array.isArray(args.by) || args.by.length === 0) {
384
431
  throw new Error("groupBy requires a non-empty `by` array");
385
432
  }
386
433
  const params = [];
387
- 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
+ });
388
439
  const groupExprs = [];
389
440
  const selects = [];
390
441
  for (const field of args.by) {
391
- ctx.onPath(field);
392
- const expr = fieldExpr(field);
442
+ if (!isColumn(field, ctx.columns)) ctx.onPath(field);
443
+ const expr = fieldExpr(field, ctx.columns);
393
444
  groupExprs.push(expr);
394
445
  selects.push(`${expr} AS "${field}"`);
395
446
  }
396
447
  selects.push(`COUNT(*) AS agg_count`);
397
- const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath);
448
+ const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
398
449
  selects.push(...accSelects);
399
450
  let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
451
+ if (args.having) {
452
+ const havingSql = buildHaving(args.having, params, ctx.columns);
453
+ if (havingSql) sql += ` HAVING ${havingSql}`;
454
+ }
400
455
  if (args.orderBy) {
401
456
  const parts = [];
402
457
  for (const key of Object.keys(args.orderBy)) {
403
458
  const dir = String(args.orderBy[key]).toLowerCase() === "desc" ? "DESC" : "ASC";
404
459
  if (key === "_count") parts.push(`agg_count ${dir}`);
405
- else parts.push(`${fieldExpr(key)} ${dir}`);
460
+ else parts.push(`${fieldExpr(key, ctx.columns)} ${dir}`);
406
461
  }
407
462
  if (parts.length) sql += ` ORDER BY ${parts.join(", ")}`;
408
463
  }
@@ -427,71 +482,216 @@ function groupBy(ctx, args) {
427
482
  }
428
483
 
429
484
  // src/collection.ts
430
- var SELECT_COLS = `_id, data, created_at, updated_at`;
431
485
  function stripSystem(obj) {
432
486
  const { _id, created_at, updated_at, ...rest } = obj;
433
487
  return rest;
434
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
+ }
435
498
  var Collection = class {
436
- constructor(mon, name) {
499
+ constructor(mon, name, options = {}) {
437
500
  this.mon = mon;
438
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
+ }
439
520
  }
440
521
  mon;
441
522
  name;
523
+ mode;
442
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;
443
531
  trackPath = (path) => this.mon.autoIndexer.track(this.name, path);
444
532
  get db() {
445
533
  return this.mon.driver;
446
534
  }
535
+ /** Native column names declared for this collection (structured mode). */
536
+ get columnNames() {
537
+ return [...this.columnOrder];
538
+ }
447
539
  ensureTable() {
448
540
  if (this.initialized) return;
449
- this.db.exec(
450
- `CREATE TABLE IF NOT EXISTS "${this.name}" (
451
- _id TEXT PRIMARY KEY,
452
- data TEXT NOT NULL,
453
- created_at INTEGER NOT NULL,
454
- updated_at INTEGER NOT NULL
455
- )`
456
- );
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
+ }
457
579
  this.initialized = true;
458
580
  }
581
+ /* --------------------------- row <-> doc -------------------------- */
459
582
  rowToDoc(row) {
460
- 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
+ }
461
591
  doc._id = row._id;
462
592
  doc.created_at = row.created_at;
463
593
  doc.updated_at = row.updated_at;
464
594
  return doc;
465
595
  }
466
- 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) {
467
614
  const now = Date.now();
468
615
  const id = input._id != null ? String(input._id) : objectId();
469
616
  const doc = stripSystem(input);
470
- return {
471
- _id: id,
472
- data: JSON.stringify(doc),
473
- created_at: now,
474
- updated_at: now
475
- };
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;
476
672
  }
477
673
  /* ----------------------------- create ----------------------------- */
478
674
  async create(args) {
479
675
  this.ensureTable();
480
- const row = this.prepareInsert(args.data);
481
- this.db.prepare(
482
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
483
- ).run(row._id, row.data, row.created_at, row.updated_at);
484
- 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;
485
685
  }
486
686
  async createMany(args) {
487
687
  this.ensureTable();
488
- const stmt = this.db.prepare(
489
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
490
- );
688
+ const stmt = this.db.prepare(this.insertSql());
689
+ const recorder = this.recorder;
491
690
  this.db.transaction(() => {
492
691
  for (const item of args.data) {
493
- const row = this.prepareInsert(item);
494
- 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);
495
695
  }
496
696
  });
497
697
  return { count: args.data.length };
@@ -500,9 +700,13 @@ var Collection = class {
500
700
  async findMany(args = {}) {
501
701
  this.ensureTable();
502
702
  const params = [];
503
- const where = buildWhere(args.where, { params, onPath: this.trackPath });
504
- let sql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${where}`;
505
- 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);
506
710
  if (order) sql += " " + order;
507
711
  if (args.take != null) {
508
712
  sql += " LIMIT ?";
@@ -513,9 +717,7 @@ var Collection = class {
513
717
  params.push(args.skip);
514
718
  }
515
719
  const rows = this.db.prepare(sql).all(...params);
516
- return rows.map(
517
- (r) => project(this.rowToDoc(r), args.select)
518
- );
720
+ return rows.map((r) => project(this.rowToDoc(r), args.select));
519
721
  }
520
722
  async findFirst(args = {}) {
521
723
  const rows = await this.findMany({ ...args, take: 1 });
@@ -523,35 +725,65 @@ var Collection = class {
523
725
  }
524
726
  async findById(id) {
525
727
  this.ensureTable();
526
- 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);
527
729
  return row ? this.rowToDoc(row) : null;
528
730
  }
529
731
  async count(args = {}) {
530
732
  this.ensureTable();
531
733
  const params = [];
532
- 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
+ });
533
739
  const row = this.db.prepare(`SELECT COUNT(*) AS n FROM "${this.name}" WHERE ${where}`).get(...params);
534
740
  return row.n;
535
741
  }
742
+ /**
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`.
745
+ */
746
+ async distinct(field, where) {
747
+ this.ensureTable();
748
+ const params = [];
749
+ const clause = buildWhere(where, {
750
+ params,
751
+ onPath: this.trackPath,
752
+ columns: this.columns
753
+ });
754
+ let sql;
755
+ if (isColumn(field, this.columns)) {
756
+ sql = `SELECT DISTINCT ${fieldExpr(field, this.columns)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
757
+ } else {
758
+ this.trackPath(field);
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`;
760
+ }
761
+ const rows = this.db.prepare(sql).all(...params);
762
+ return rows.map((r) => r.v);
763
+ }
536
764
  /* ----------------------------- update ----------------------------- */
537
765
  runUpdate(where, data, single) {
538
766
  this.ensureTable();
539
767
  const params = [];
540
- const clause = buildWhere(where, { params, onPath: this.trackPath });
541
- 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}`;
542
774
  if (single) selectSql += " LIMIT 1";
543
775
  const rows = this.db.prepare(selectSql).all(...params);
544
776
  if (!rows.length) return [];
545
777
  const now = Date.now();
546
- const stmt = this.db.prepare(
547
- `UPDATE "${this.name}" SET data = ?, updated_at = ? WHERE _id = ?`
548
- );
778
+ const recorder = this.recorder;
549
779
  return this.db.transaction(() => {
550
780
  const out = [];
551
781
  for (const row of rows) {
552
- const current = JSON.parse(row.data);
782
+ const current = stripSystem(this.rowToDoc(row));
553
783
  const updated = stripSystem(applyUpdate(current, data));
554
- 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);
555
787
  out.push({
556
788
  ...updated,
557
789
  _id: row._id,
@@ -584,14 +816,23 @@ var Collection = class {
584
816
  runDelete(where, single) {
585
817
  this.ensureTable();
586
818
  const params = [];
587
- const clause = buildWhere(where, { params, onPath: this.trackPath });
588
- 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}`;
589
825
  if (single) selectSql += " LIMIT 1";
590
826
  const rows = this.db.prepare(selectSql).all(...params);
591
827
  if (!rows.length) return [];
592
828
  const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
829
+ const recorder = this.recorder;
830
+ const now = Date.now();
593
831
  this.db.transaction(() => {
594
- 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
+ }
595
836
  });
596
837
  return rows.map((r) => this.rowToDoc(r));
597
838
  }
@@ -605,14 +846,14 @@ var Collection = class {
605
846
  async aggregate(args = {}) {
606
847
  this.ensureTable();
607
848
  return aggregate(
608
- { db: this.db, table: this.name, onPath: this.trackPath },
849
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
609
850
  args
610
851
  );
611
852
  }
612
853
  async groupBy(args) {
613
854
  this.ensureTable();
614
855
  return groupBy(
615
- { db: this.db, table: this.name, onPath: this.trackPath },
856
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
616
857
  args
617
858
  );
618
859
  }
@@ -798,6 +1039,347 @@ function createDriver(filename, options = {}) {
798
1039
  );
799
1040
  }
800
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
+
801
1383
  // src/db.ts
802
1384
  function validateName(name) {
803
1385
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
@@ -823,6 +1405,8 @@ var Monlite = class {
823
1405
  driver;
824
1406
  /** @internal */
825
1407
  autoIndexer;
1408
+ /** @internal Sync metadata store; present only when `{ sync: true }`. */
1409
+ $sync;
826
1410
  collections = /* @__PURE__ */ new Map();
827
1411
  closed = false;
828
1412
  constructor(filename, options = {}) {
@@ -837,6 +1421,13 @@ var Monlite = class {
837
1421
  options.autoIndex ?? true,
838
1422
  options.autoIndexAfter ?? 10
839
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;
840
1431
  }
841
1432
  /** The underlying native database handle (escape hatch). */
842
1433
  get sqlite() {
@@ -846,17 +1437,35 @@ var Monlite = class {
846
1437
  get driverName() {
847
1438
  return this.driver.name;
848
1439
  }
849
- /** Get (or lazily create) a typed collection handle. */
850
- 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) {
851
1446
  this.assertOpen();
852
1447
  validateName(name);
853
1448
  let col = this.collections.get(name);
854
1449
  if (!col) {
855
- col = new Collection(this, name);
1450
+ col = new Collection(this, name, options);
856
1451
  this.collections.set(name, col);
857
1452
  }
858
1453
  return col;
859
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
+ }
860
1469
  /** Tagged-template SQL query returning rows. Values are safely parameterized. */
861
1470
  $queryRaw(strings, ...values) {
862
1471
  this.assertOpen();
@@ -896,7 +1505,9 @@ var Monlite = class {
896
1505
  this.assertOpen();
897
1506
  const rows = this.driver.prepare(
898
1507
  `SELECT name FROM sqlite_master
899
- 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 '\\'
900
1511
  ORDER BY name`
901
1512
  ).all();
902
1513
  return Promise.resolve(rows.map((r) => r.name));
@@ -934,8 +1545,12 @@ exports.Collection = Collection;
934
1545
  exports.Monlite = Monlite;
935
1546
  exports.MonliteError = MonliteError;
936
1547
  exports.MonliteQueryError = MonliteQueryError;
1548
+ exports.SyncStore = SyncStore;
1549
+ exports.compareVersions = compareVersions;
937
1550
  exports.createDb = createDb;
938
1551
  exports.isObjectId = isObjectId;
1552
+ exports.makeVersion = makeVersion;
939
1553
  exports.objectId = objectId;
1554
+ exports.versionTs = versionTs;
940
1555
  //# sourceMappingURL=index.cjs.map
941
1556
  //# sourceMappingURL=index.cjs.map