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