@monlite/core 0.3.0 → 0.6.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
@@ -22,23 +22,73 @@ function isObjectId(value) {
22
22
 
23
23
  // src/errors.ts
24
24
  var MonliteError = class extends Error {
25
- constructor(message) {
25
+ constructor(message, options) {
26
26
  super(message);
27
27
  this.name = "MonliteError";
28
+ if (options?.cause !== void 0) this.cause = options.cause;
28
29
  }
29
30
  };
30
31
  var MonliteQueryError = class extends MonliteError {
31
- constructor(message) {
32
- super(message);
32
+ constructor(message, options) {
33
+ super(message, options);
33
34
  this.name = "MonliteQueryError";
34
35
  }
35
36
  };
37
+ var MonliteConstraintError = class extends MonliteError {
38
+ collection;
39
+ constructor(message, options) {
40
+ super(message, options);
41
+ this.name = "MonliteConstraintError";
42
+ this.collection = options?.collection;
43
+ }
44
+ };
45
+ var MonliteUniqueConstraintError = class extends MonliteConstraintError {
46
+ constructor(message, options) {
47
+ super(message, options);
48
+ this.name = "MonliteUniqueConstraintError";
49
+ }
50
+ };
51
+ var MonliteNotNullError = class extends MonliteConstraintError {
52
+ constructor(message, options) {
53
+ super(message, options);
54
+ this.name = "MonliteNotNullError";
55
+ }
56
+ };
57
+ var MonliteForeignKeyError = class extends MonliteConstraintError {
58
+ constructor(message, options) {
59
+ super(message, options);
60
+ this.name = "MonliteForeignKeyError";
61
+ }
62
+ };
63
+ function normalizeDriverError(err, collection) {
64
+ if (err instanceof MonliteError) return err;
65
+ const code = err?.code ? String(err.code) : "";
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ const blob = `${code} ${message}`;
68
+ const opts = { cause: err, collection };
69
+ if (/UNIQUE|PRIMARY KEY|constraint failed: .*\.(?:_id)\b/i.test(blob)) {
70
+ return new MonliteUniqueConstraintError(message, opts);
71
+ }
72
+ if (/NOT ?NULL/i.test(blob)) {
73
+ return new MonliteNotNullError(message, opts);
74
+ }
75
+ if (/FOREIGN ?KEY/i.test(blob)) {
76
+ return new MonliteForeignKeyError(message, opts);
77
+ }
78
+ if (/CONSTRAINT/i.test(blob)) {
79
+ return new MonliteConstraintError(message, opts);
80
+ }
81
+ return new MonliteError(message, { cause: err });
82
+ }
36
83
 
37
84
  // src/query/sql.ts
38
85
  var RESERVED_FIELDS = /* @__PURE__ */ new Set(["_id", "created_at", "updated_at"]);
39
86
  function isReserved(field) {
40
87
  return RESERVED_FIELDS.has(field);
41
88
  }
89
+ function isColumn(field, columns) {
90
+ return isReserved(field) || (columns?.has(field) ?? false);
91
+ }
42
92
  function jsonPath(field) {
43
93
  let path = "$";
44
94
  for (const seg of field.split(".")) {
@@ -55,10 +105,13 @@ function jsonPath(field) {
55
105
  function pathLiteral(field) {
56
106
  return "'" + jsonPath(field).replace(/'/g, "''") + "'";
57
107
  }
58
- function fieldExpr(field) {
59
- if (isReserved(field)) return `"${field}"`;
108
+ function fieldExpr(field, columns) {
109
+ if (isColumn(field, columns)) return quoteIdent(field);
60
110
  return `json_extract(data, ${pathLiteral(field)})`;
61
111
  }
112
+ function quoteIdent(name) {
113
+ return `"${name.replace(/"/g, '""')}"`;
114
+ }
62
115
  function bindable(value) {
63
116
  if (value === void 0 || value === null) return null;
64
117
  if (typeof value === "boolean") return value ? 1 : 0;
@@ -103,16 +156,17 @@ function isFilterObject(v) {
103
156
  return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date) && !Buffer.isBuffer(v) && (v.constructor === Object || v.constructor === void 0);
104
157
  }
105
158
  function translateField(field, condition, ctx) {
106
- if (ctx.onPath && !isReserved(field)) ctx.onPath(field);
107
- const expr = fieldExpr(field);
159
+ if (ctx.onPath && !isColumn(field, ctx.columns)) ctx.onPath(field);
160
+ const expr = fieldExpr(field, ctx.columns);
108
161
  if (!isFilterObject(condition)) {
109
162
  return eqExpr(expr, condition, ctx);
110
163
  }
111
164
  const filter = condition;
165
+ const ci = filter.mode === "insensitive";
112
166
  const clauses = [];
113
167
  for (const op of Object.keys(filter)) {
114
168
  const v = filter[op];
115
- if (v === void 0) continue;
169
+ if (v === void 0 || op === "mode") continue;
116
170
  switch (op) {
117
171
  case "equals":
118
172
  clauses.push(eqExpr(expr, v, ctx));
@@ -139,22 +193,26 @@ function translateField(field, condition, ctx) {
139
193
  clauses.push(inExpr(expr, v, ctx, true));
140
194
  break;
141
195
  case "contains":
142
- clauses.push(containsExpr(field, expr, v, ctx));
196
+ clauses.push(containsExpr(field, expr, v, ctx, ci));
143
197
  break;
144
198
  case "startsWith":
145
199
  ctx.params.push(bindable(v));
146
- clauses.push(`instr(${expr}, ?) = 1`);
200
+ clauses.push(
201
+ ci ? `instr(lower(${expr}), lower(?)) = 1` : `instr(${expr}, ?) = 1`
202
+ );
147
203
  break;
148
204
  case "endsWith":
149
205
  ctx.params.push(bindable(v));
150
206
  ctx.params.push(bindable(v));
151
- clauses.push(`substr(${expr}, -length(?)) = ?`);
207
+ clauses.push(
208
+ ci ? `substr(lower(${expr}), -length(?)) = lower(?)` : `substr(${expr}, -length(?)) = ?`
209
+ );
152
210
  break;
153
211
  case "has":
154
212
  clauses.push(hasExpr(field, expr, v, ctx));
155
213
  break;
156
214
  case "exists":
157
- clauses.push(existsExpr(field, expr, !!v));
215
+ clauses.push(existsExpr(field, expr, !!v, ctx.columns));
158
216
  break;
159
217
  default:
160
218
  throw new MonliteQueryError(
@@ -192,23 +250,25 @@ function inExpr(expr, arr, ctx, negate) {
192
250
  }).join(", ");
193
251
  return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
194
252
  }
195
- function containsExpr(field, expr, v, ctx) {
196
- if (isReserved(field)) {
253
+ function containsExpr(field, expr, v, ctx, ci) {
254
+ const sub = ci ? `instr(lower(${expr}), lower(?)) > 0` : `instr(${expr}, ?) > 0`;
255
+ if (isColumn(field, ctx.columns)) {
197
256
  ctx.params.push(bindable(v));
198
- return `instr(${expr}, ?) > 0`;
257
+ return sub;
199
258
  }
200
259
  const path = pathLiteral(field);
260
+ const member = ci ? `lower(value) = lower(?)` : `value = ?`;
201
261
  ctx.params.push(bindable(v));
202
262
  ctx.params.push(bindable(v));
203
- return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE value = ?) ELSE instr(${expr}, ?) > 0 END)`;
263
+ return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE ${member}) ELSE ${sub} END)`;
204
264
  }
205
265
  function hasExpr(field, expr, v, ctx) {
206
266
  ctx.params.push(bindable(v));
207
- if (isReserved(field)) return `${expr} = ?`;
267
+ if (isColumn(field, ctx.columns)) return `${expr} = ?`;
208
268
  return `EXISTS (SELECT 1 FROM json_each(data, ${pathLiteral(field)}) WHERE value = ?)`;
209
269
  }
210
- function existsExpr(field, expr, want) {
211
- if (isReserved(field)) {
270
+ function existsExpr(field, expr, want, columns) {
271
+ if (isColumn(field, columns)) {
212
272
  return want ? `${expr} IS NOT NULL` : `${expr} IS NULL`;
213
273
  }
214
274
  const path = pathLiteral(field);
@@ -216,7 +276,7 @@ function existsExpr(field, expr, want) {
216
276
  }
217
277
 
218
278
  // src/query/order.ts
219
- function buildOrderBy(orderBy, onPath) {
279
+ function buildOrderBy(orderBy, onPath, columns) {
220
280
  if (!orderBy) return "";
221
281
  const list = Array.isArray(orderBy) ? orderBy : [orderBy];
222
282
  const parts = [];
@@ -224,25 +284,35 @@ function buildOrderBy(orderBy, onPath) {
224
284
  for (const field of Object.keys(obj)) {
225
285
  const dir = obj[field];
226
286
  if (dir === void 0) continue;
227
- if (onPath && !isReserved(field)) onPath(field);
287
+ if (onPath && !isColumn(field, columns)) onPath(field);
228
288
  const d = String(dir).toLowerCase() === "desc" ? "DESC" : "ASC";
229
- parts.push(`${fieldExpr(field)} ${d}`);
289
+ parts.push(`${fieldExpr(field, columns)} ${d}`);
230
290
  }
231
291
  }
232
292
  return parts.length ? "ORDER BY " + parts.join(", ") : "";
233
293
  }
234
294
 
235
295
  // src/query/path.ts
296
+ var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
297
+ function safeSegments(path) {
298
+ const segs = path.split(".");
299
+ for (const seg of segs) {
300
+ if (FORBIDDEN.has(seg)) {
301
+ throw new MonliteQueryError(`Illegal path segment "${seg}" in "${path}"`);
302
+ }
303
+ }
304
+ return segs;
305
+ }
236
306
  function getPath(obj, path) {
237
307
  let cur = obj;
238
- for (const seg of path.split(".")) {
308
+ for (const seg of safeSegments(path)) {
239
309
  if (cur == null) return void 0;
240
310
  cur = cur[seg];
241
311
  }
242
312
  return cur;
243
313
  }
244
314
  function setPath(obj, path, value) {
245
- const segs = path.split(".");
315
+ const segs = safeSegments(path);
246
316
  let cur = obj;
247
317
  for (let i = 0; i < segs.length - 1; i++) {
248
318
  const seg = segs[i];
@@ -252,7 +322,7 @@ function setPath(obj, path, value) {
252
322
  cur[segs[segs.length - 1]] = value;
253
323
  }
254
324
  function unsetPath(obj, path) {
255
- const segs = path.split(".");
325
+ const segs = safeSegments(path);
256
326
  let cur = obj;
257
327
  for (let i = 0; i < segs.length - 1; i++) {
258
328
  const seg = segs[i];
@@ -305,8 +375,13 @@ function applyUpdate(doc, data) {
305
375
  }
306
376
  if (ops.$inc) {
307
377
  for (const [path, by] of Object.entries(ops.$inc)) {
378
+ if (typeof by !== "number" || !Number.isFinite(by)) {
379
+ throw new MonliteQueryError(
380
+ `$inc on "${path}" requires a finite number, got ${JSON.stringify(by)}`
381
+ );
382
+ }
308
383
  const cur = getPath(next, path);
309
- setPath(next, path, (typeof cur === "number" ? cur : 0) + Number(by));
384
+ setPath(next, path, (typeof cur === "number" ? cur : 0) + by);
310
385
  }
311
386
  }
312
387
  if (ops.$push) {
@@ -347,7 +422,7 @@ var SQL_FN = {
347
422
  _min: "MIN",
348
423
  _max: "MAX"
349
424
  };
350
- function buildAccumulators(args, onPath) {
425
+ function buildAccumulators(args, onPath, columns) {
351
426
  const selects = [];
352
427
  const cols = [];
353
428
  let i = 0;
@@ -356,9 +431,9 @@ function buildAccumulators(args, onPath) {
356
431
  if (!selection) continue;
357
432
  for (const field of Object.keys(selection)) {
358
433
  if (!selection[field]) continue;
359
- onPath(field);
434
+ if (!isColumn(field, columns)) onPath(field);
360
435
  const alias = `agg_${kind.slice(1)}_${i++}`;
361
- selects.push(`${SQL_FN[kind]}(${fieldExpr(field)}) AS ${alias}`);
436
+ selects.push(`${SQL_FN[kind]}(${fieldExpr(field, columns)}) AS ${alias}`);
362
437
  cols.push({ alias, kind, field });
363
438
  }
364
439
  }
@@ -366,8 +441,12 @@ function buildAccumulators(args, onPath) {
366
441
  }
367
442
  function aggregate(ctx, args) {
368
443
  const params = [];
369
- const where = buildWhere(args.where, { params, onPath: ctx.onPath });
370
- const { selects, cols } = buildAccumulators(args, ctx.onPath);
444
+ const where = buildWhere(args.where, {
445
+ params,
446
+ onPath: ctx.onPath,
447
+ columns: ctx.columns
448
+ });
449
+ const { selects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
371
450
  const allSelects = [`COUNT(*) AS agg_count`, ...selects];
372
451
  const sql = `SELECT ${allSelects.join(", ")} FROM "${ctx.table}" WHERE ${where}`;
373
452
  const row = ctx.db.prepare(sql).get(...params) ?? {};
@@ -403,7 +482,7 @@ function comparisonSql(expr, cmp2, params) {
403
482
  }
404
483
  return out;
405
484
  }
406
- function buildHaving(having, params) {
485
+ function buildHaving(having, params, columns) {
407
486
  const parts = [];
408
487
  if (having._count) {
409
488
  parts.push(...comparisonSql("COUNT(*)", having._count, params));
@@ -412,31 +491,40 @@ function buildHaving(having, params) {
412
491
  const selection = having[kind];
413
492
  if (!selection) continue;
414
493
  for (const field of Object.keys(selection)) {
415
- parts.push(...comparisonSql(`${fn}(${fieldExpr(field)})`, selection[field], params));
494
+ parts.push(
495
+ ...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
496
+ );
416
497
  }
417
498
  }
418
499
  return parts.join(" AND ");
419
500
  }
420
501
  function groupBy(ctx, args) {
421
502
  if (!Array.isArray(args.by) || args.by.length === 0) {
422
- throw new Error("groupBy requires a non-empty `by` array");
503
+ throw new MonliteQueryError("groupBy requires a non-empty `by` array");
423
504
  }
424
505
  const params = [];
425
- const where = buildWhere(args.where, { params, onPath: ctx.onPath });
506
+ const where = buildWhere(args.where, {
507
+ params,
508
+ onPath: ctx.onPath,
509
+ columns: ctx.columns
510
+ });
426
511
  const groupExprs = [];
512
+ const groupCols = [];
427
513
  const selects = [];
428
- for (const field of args.by) {
429
- ctx.onPath(field);
430
- const expr = fieldExpr(field);
514
+ args.by.forEach((field, gi) => {
515
+ if (!isColumn(field, ctx.columns)) ctx.onPath(field);
516
+ const expr = fieldExpr(field, ctx.columns);
517
+ const alias = `grp_${gi}`;
431
518
  groupExprs.push(expr);
432
- selects.push(`${expr} AS "${field}"`);
433
- }
519
+ selects.push(`${expr} AS ${alias}`);
520
+ groupCols.push({ alias, field });
521
+ });
434
522
  selects.push(`COUNT(*) AS agg_count`);
435
- const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath);
523
+ const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
436
524
  selects.push(...accSelects);
437
525
  let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
438
526
  if (args.having) {
439
- const havingSql = buildHaving(args.having, params);
527
+ const havingSql = buildHaving(args.having, params, ctx.columns);
440
528
  if (havingSql) sql += ` HAVING ${havingSql}`;
441
529
  }
442
530
  if (args.orderBy) {
@@ -444,7 +532,7 @@ function groupBy(ctx, args) {
444
532
  for (const key of Object.keys(args.orderBy)) {
445
533
  const dir = String(args.orderBy[key]).toLowerCase() === "desc" ? "DESC" : "ASC";
446
534
  if (key === "_count") parts.push(`agg_count ${dir}`);
447
- else parts.push(`${fieldExpr(key)} ${dir}`);
535
+ else parts.push(`${fieldExpr(key, ctx.columns)} ${dir}`);
448
536
  }
449
537
  if (parts.length) sql += ` ORDER BY ${parts.join(", ")}`;
450
538
  }
@@ -459,7 +547,7 @@ function groupBy(ctx, args) {
459
547
  const rows = ctx.db.prepare(sql).all(...params);
460
548
  return rows.map((row) => {
461
549
  const out = {};
462
- for (const field of args.by) out[field] = row[field];
550
+ for (const { alias, field } of groupCols) out[field] = row[alias];
463
551
  if (args._count) out._count = row.agg_count;
464
552
  for (const col of cols) {
465
553
  (out[col.kind] ??= {})[col.field] = row[col.alias] ?? null;
@@ -469,82 +557,256 @@ function groupBy(ctx, args) {
469
557
  }
470
558
 
471
559
  // src/collection.ts
472
- var SELECT_COLS = `_id, data, created_at, updated_at`;
473
560
  function stripSystem(obj) {
474
561
  const { _id, created_at, updated_at, ...rest } = obj;
475
562
  return rest;
476
563
  }
564
+ var NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
565
+ function sqliteType(type) {
566
+ return type === "JSON" ? "TEXT" : type;
567
+ }
568
+ function formatDefault(value) {
569
+ if (value === null) return "NULL";
570
+ if (typeof value === "number") return String(value);
571
+ return `'${String(value).replace(/'/g, "''")}'`;
572
+ }
477
573
  var Collection = class {
478
- constructor(mon, name) {
574
+ constructor(mon, name, options = {}) {
479
575
  this.mon = mon;
480
576
  this.name = name;
577
+ this.mode = options.schema ? "structured" : "document";
578
+ if (options.schema) {
579
+ for (const [field, def] of Object.entries(options.schema)) {
580
+ if (!NAME_RE.test(field)) {
581
+ throw new MonliteError(`Invalid column name "${field}"`);
582
+ }
583
+ if (RESERVED_FIELDS.has(field) || field === "data") {
584
+ throw new MonliteError(
585
+ `Column "${field}" is reserved by monlite and cannot be declared`
586
+ );
587
+ }
588
+ const normalized = typeof def === "string" ? { type: def } : def;
589
+ if (normalized.references && !/^[A-Za-z_][A-Za-z0-9_]*(\([A-Za-z_][A-Za-z0-9_]*\))?$/.test(
590
+ normalized.references
591
+ )) {
592
+ throw new MonliteError(
593
+ `Invalid references "${normalized.references}" on column "${field}"`
594
+ );
595
+ }
596
+ this.columnDefs[field] = normalized;
597
+ this.columnOrder.push(field);
598
+ this.columns.add(field);
599
+ if (normalized.type === "JSON") this.jsonColumns.add(field);
600
+ }
601
+ }
481
602
  }
482
603
  mon;
483
604
  name;
605
+ mode;
484
606
  initialized = false;
607
+ columnDefs = {};
608
+ columnOrder = [];
609
+ /** Declared native columns (empty in document mode). */
610
+ columns = /* @__PURE__ */ new Set();
611
+ jsonColumns = /* @__PURE__ */ new Set();
612
+ insertSqlCache;
485
613
  trackPath = (path) => this.mon.autoIndexer.track(this.name, path);
486
614
  get db() {
487
615
  return this.mon.driver;
488
616
  }
617
+ /** Run a DB operation, normalizing driver errors into typed MonliteErrors. */
618
+ guard(fn) {
619
+ try {
620
+ return fn();
621
+ } catch (err) {
622
+ throw normalizeDriverError(err, this.name);
623
+ }
624
+ }
625
+ /** Native column names declared for this collection (structured mode). */
626
+ get columnNames() {
627
+ return [...this.columnOrder];
628
+ }
489
629
  ensureTable() {
490
630
  if (this.initialized) return;
491
- this.db.exec(
492
- `CREATE TABLE IF NOT EXISTS "${this.name}" (
493
- _id TEXT PRIMARY KEY,
494
- data TEXT NOT NULL,
495
- created_at INTEGER NOT NULL,
496
- updated_at INTEGER NOT NULL
497
- )`
498
- );
631
+ if (this.mode === "document") {
632
+ this.db.exec(
633
+ `CREATE TABLE IF NOT EXISTS "${this.name}" (
634
+ _id TEXT PRIMARY KEY,
635
+ data TEXT NOT NULL,
636
+ created_at INTEGER NOT NULL,
637
+ updated_at INTEGER NOT NULL
638
+ )`
639
+ );
640
+ } else {
641
+ const lines = [
642
+ `_id TEXT PRIMARY KEY`,
643
+ `created_at INTEGER NOT NULL`,
644
+ `updated_at INTEGER NOT NULL`,
645
+ `data TEXT NOT NULL DEFAULT '{}'`
646
+ ];
647
+ for (const field of this.columnOrder) {
648
+ const def = this.columnDefs[field];
649
+ let line = `"${field}" ${sqliteType(def.type)}`;
650
+ if (def.notNull) line += " NOT NULL";
651
+ if (def.unique) line += " UNIQUE";
652
+ if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
653
+ if (def.references) line += ` REFERENCES ${def.references}`;
654
+ lines.push(line);
655
+ }
656
+ this.db.exec(
657
+ `CREATE TABLE IF NOT EXISTS "${this.name}" (
658
+ ${lines.join(",\n ")}
659
+ )`
660
+ );
661
+ for (const field of this.columnOrder) {
662
+ if (this.columnDefs[field].index) {
663
+ this.db.exec(
664
+ `CREATE INDEX IF NOT EXISTS "idx_${this.name}_${field}" ON "${this.name}"("${field}")`
665
+ );
666
+ }
667
+ }
668
+ }
499
669
  this.initialized = true;
500
670
  }
671
+ /* --------------------------- row <-> doc -------------------------- */
501
672
  rowToDoc(row) {
502
- const doc = JSON.parse(row.data);
673
+ const doc = this.mode === "document" ? JSON.parse(row.data) : JSON.parse(row.data ?? "{}");
674
+ if (this.mode === "structured") {
675
+ for (const field of this.columnOrder) {
676
+ const value = row[field];
677
+ if (value === void 0) continue;
678
+ if (value === null) {
679
+ doc[field] = null;
680
+ continue;
681
+ }
682
+ doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
683
+ }
684
+ }
503
685
  doc._id = row._id;
504
686
  doc.created_at = row.created_at;
505
687
  doc.updated_at = row.updated_at;
506
688
  return doc;
507
689
  }
508
- prepareInsert(input) {
690
+ encodeColumn(field, value) {
691
+ if (this.jsonColumns.has(field)) {
692
+ return value === void 0 ? null : JSON.stringify(value);
693
+ }
694
+ if (value !== null && typeof value === "object" && !(value instanceof Date) && !Buffer.isBuffer(value)) {
695
+ throw new MonliteQueryError(
696
+ `Column "${field}" cannot store an object/array. Declare it as { type: "JSON" } to store structured values.`
697
+ );
698
+ }
699
+ return bindable(value);
700
+ }
701
+ insertColumns() {
702
+ return this.mode === "document" ? ["_id", "data", "created_at", "updated_at"] : ["_id", "created_at", "updated_at", "data", ...this.columnOrder];
703
+ }
704
+ insertSql() {
705
+ if (this.insertSqlCache) return this.insertSqlCache;
706
+ const cols = this.insertColumns();
707
+ const list = cols.map((c) => `"${c}"`).join(", ");
708
+ const placeholders = cols.map(() => "?").join(", ");
709
+ return this.insertSqlCache = `INSERT INTO "${this.name}" (${list}) VALUES (${placeholders})`;
710
+ }
711
+ /** Split an input document into a row aligned with `insertColumns()`. */
712
+ buildInsert(input) {
509
713
  const now = Date.now();
510
714
  const id = input._id != null ? String(input._id) : objectId();
511
715
  const doc = stripSystem(input);
512
- return {
513
- _id: id,
514
- data: JSON.stringify(doc),
515
- created_at: now,
516
- updated_at: now
517
- };
716
+ const returned = { ...doc, _id: id, created_at: now, updated_at: now };
717
+ if (this.mode === "document") {
718
+ return {
719
+ _id: id,
720
+ created_at: now,
721
+ updated_at: now,
722
+ values: [id, JSON.stringify(doc), now, now],
723
+ returned
724
+ };
725
+ }
726
+ const overflow = {};
727
+ const colValues = {};
728
+ for (const [k, v] of Object.entries(doc)) {
729
+ if (this.columns.has(k)) colValues[k] = v;
730
+ else overflow[k] = v;
731
+ }
732
+ const values = [
733
+ id,
734
+ now,
735
+ now,
736
+ JSON.stringify(overflow),
737
+ ...this.columnOrder.map(
738
+ (c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
739
+ )
740
+ ];
741
+ return { _id: id, created_at: now, updated_at: now, values, returned };
742
+ }
743
+ /** Build the `SET` clause + values to persist an updated document. */
744
+ buildUpdateSet(updatedDoc, now) {
745
+ if (this.mode === "document") {
746
+ return {
747
+ setSql: `data = ?, updated_at = ?`,
748
+ values: [JSON.stringify(updatedDoc), now]
749
+ };
750
+ }
751
+ const overflow = {};
752
+ const colValues = {};
753
+ for (const [k, v] of Object.entries(updatedDoc)) {
754
+ if (this.columns.has(k)) colValues[k] = v;
755
+ else overflow[k] = v;
756
+ }
757
+ const setParts = this.columnOrder.map((c) => `"${c}" = ?`);
758
+ setParts.push(`data = ?`, `updated_at = ?`);
759
+ const values = [
760
+ ...this.columnOrder.map(
761
+ (c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
762
+ ),
763
+ JSON.stringify(overflow),
764
+ now
765
+ ];
766
+ return { setSql: setParts.join(", "), values };
767
+ }
768
+ /** Sync store, but only for document collections (structured sync is future work). */
769
+ get recorder() {
770
+ return this.mode === "document" ? this.mon.$sync : void 0;
518
771
  }
519
772
  /* ----------------------------- create ----------------------------- */
520
773
  async create(args) {
521
774
  this.ensureTable();
522
- const row = this.prepareInsert(args.data);
523
- this.db.prepare(
524
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
525
- ).run(row._id, row.data, row.created_at, row.updated_at);
526
- return this.rowToDoc(row);
775
+ const row = this.buildInsert(args.data);
776
+ const recorder = this.recorder;
777
+ const write = () => {
778
+ this.db.prepare(this.insertSql()).run(...row.values);
779
+ recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
780
+ };
781
+ this.guard(() => recorder ? this.db.transaction(write) : write());
782
+ return row.returned;
527
783
  }
528
784
  async createMany(args) {
529
785
  this.ensureTable();
530
- const stmt = this.db.prepare(
531
- `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)`
786
+ const stmt = this.db.prepare(this.insertSql());
787
+ const recorder = this.recorder;
788
+ this.guard(
789
+ () => this.db.transaction(() => {
790
+ for (const item of args.data) {
791
+ const row = this.buildInsert(item);
792
+ stmt.run(...row.values);
793
+ recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
794
+ }
795
+ })
532
796
  );
533
- this.db.transaction(() => {
534
- for (const item of args.data) {
535
- const row = this.prepareInsert(item);
536
- stmt.run(row._id, row.data, row.created_at, row.updated_at);
537
- }
538
- });
539
797
  return { count: args.data.length };
540
798
  }
541
799
  /* ------------------------------ read ------------------------------ */
542
800
  async findMany(args = {}) {
543
801
  this.ensureTable();
544
802
  const params = [];
545
- const where = buildWhere(args.where, { params, onPath: this.trackPath });
546
- let sql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${where}`;
547
- const order = buildOrderBy(args.orderBy, this.trackPath);
803
+ const where = buildWhere(args.where, {
804
+ params,
805
+ onPath: this.trackPath,
806
+ columns: this.columns
807
+ });
808
+ let sql = `SELECT * FROM "${this.name}" WHERE ${where}`;
809
+ const order = buildOrderBy(args.orderBy, this.trackPath, this.columns);
548
810
  if (order) sql += " " + order;
549
811
  if (args.take != null) {
550
812
  sql += " LIMIT ?";
@@ -555,72 +817,108 @@ var Collection = class {
555
817
  params.push(args.skip);
556
818
  }
557
819
  const rows = this.db.prepare(sql).all(...params);
558
- return rows.map(
559
- (r) => project(this.rowToDoc(r), args.select)
560
- );
820
+ return rows.map((r) => project(this.rowToDoc(r), args.select));
561
821
  }
562
822
  async findFirst(args = {}) {
563
823
  const rows = await this.findMany({ ...args, take: 1 });
564
824
  return rows[0] ?? null;
565
825
  }
826
+ /** Alias of {@link findFirst} for Prisma familiarity. */
827
+ async findUnique(args = {}) {
828
+ return this.findFirst(args);
829
+ }
830
+ /** Like {@link findFirst} but throws if no document matches. */
831
+ async findFirstOrThrow(args = {}) {
832
+ const doc = await this.findFirst(args);
833
+ if (!doc) throw new MonliteError(`No document found in "${this.name}"`);
834
+ return doc;
835
+ }
836
+ /** True if at least one document matches. */
837
+ async exists(where) {
838
+ this.ensureTable();
839
+ const params = [];
840
+ const clause = buildWhere(where, {
841
+ params,
842
+ onPath: this.trackPath,
843
+ columns: this.columns
844
+ });
845
+ const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
846
+ return row != null;
847
+ }
566
848
  async findById(id) {
567
849
  this.ensureTable();
568
- const row = this.db.prepare(`SELECT ${SELECT_COLS} FROM "${this.name}" WHERE _id = ?`).get(id);
850
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
569
851
  return row ? this.rowToDoc(row) : null;
570
852
  }
571
853
  async count(args = {}) {
572
854
  this.ensureTable();
573
855
  const params = [];
574
- const where = buildWhere(args.where, { params, onPath: this.trackPath });
856
+ const where = buildWhere(args.where, {
857
+ params,
858
+ onPath: this.trackPath,
859
+ columns: this.columns
860
+ });
575
861
  const row = this.db.prepare(`SELECT COUNT(*) AS n FROM "${this.name}" WHERE ${where}`).get(...params);
576
862
  return row.n;
577
863
  }
578
864
  /**
579
- * Return the distinct values of a field across the collection. Array fields
580
- * are unwound (each element counts as a value), matching MongoDB's `distinct`.
865
+ * Return the distinct values of a field. Array fields stored in JSON are
866
+ * unwound (each element counts as a value), matching MongoDB's `distinct`.
581
867
  */
582
868
  async distinct(field, where) {
583
869
  this.ensureTable();
584
870
  const params = [];
585
- const clause = buildWhere(where, { params, onPath: this.trackPath });
871
+ const clause = buildWhere(where, {
872
+ params,
873
+ onPath: this.trackPath,
874
+ columns: this.columns
875
+ });
586
876
  let sql;
587
- if (isReserved(field)) {
588
- sql = `SELECT DISTINCT ${fieldExpr(field)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
877
+ if (isColumn(field, this.columns)) {
878
+ sql = `SELECT DISTINCT ${fieldExpr(field, this.columns)} AS v FROM "${this.name}" WHERE ${clause} ORDER BY v`;
589
879
  } else {
590
880
  this.trackPath(field);
591
881
  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`;
592
882
  }
593
- const rows = this.db.prepare(sql).all(...params);
594
- return rows.map((r) => r.v);
883
+ return this.guard(() => {
884
+ const rows = this.db.prepare(sql).all(...params);
885
+ return rows.map((r) => r.v);
886
+ });
595
887
  }
596
888
  /* ----------------------------- update ----------------------------- */
597
889
  runUpdate(where, data, single) {
598
890
  this.ensureTable();
599
891
  const params = [];
600
- const clause = buildWhere(where, { params, onPath: this.trackPath });
601
- let selectSql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${clause}`;
892
+ const clause = buildWhere(where, {
893
+ params,
894
+ onPath: this.trackPath,
895
+ columns: this.columns
896
+ });
897
+ let selectSql = `SELECT * FROM "${this.name}" WHERE ${clause}`;
602
898
  if (single) selectSql += " LIMIT 1";
603
899
  const rows = this.db.prepare(selectSql).all(...params);
604
900
  if (!rows.length) return [];
605
901
  const now = Date.now();
606
- const stmt = this.db.prepare(
607
- `UPDATE "${this.name}" SET data = ?, updated_at = ? WHERE _id = ?`
902
+ const recorder = this.recorder;
903
+ return this.guard(
904
+ () => this.db.transaction(() => {
905
+ const out = [];
906
+ for (const row of rows) {
907
+ const current = stripSystem(this.rowToDoc(row));
908
+ const updated = stripSystem(applyUpdate(current, data));
909
+ const { setSql, values } = this.buildUpdateSet(updated, now);
910
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
911
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
912
+ out.push({
913
+ ...updated,
914
+ _id: row._id,
915
+ created_at: row.created_at,
916
+ updated_at: now
917
+ });
918
+ }
919
+ return out;
920
+ })
608
921
  );
609
- return this.db.transaction(() => {
610
- const out = [];
611
- for (const row of rows) {
612
- const current = JSON.parse(row.data);
613
- const updated = stripSystem(applyUpdate(current, data));
614
- stmt.run(JSON.stringify(updated), now, row._id);
615
- out.push({
616
- ...updated,
617
- _id: row._id,
618
- created_at: row.created_at,
619
- updated_at: now
620
- });
621
- }
622
- return out;
623
- });
624
922
  }
625
923
  async update(args) {
626
924
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -630,29 +928,61 @@ var Collection = class {
630
928
  }
631
929
  async upsert(args) {
632
930
  this.ensureTable();
633
- const existing = await this.findFirst({ where: args.where });
634
- if (existing) {
635
- const updated = await this.update({
636
- where: { _id: existing._id },
637
- data: args.update
638
- });
639
- return updated;
640
- }
641
- return this.create({ data: args.create });
931
+ return this.guard(
932
+ () => this.db.transaction(() => {
933
+ const params = [];
934
+ const clause = buildWhere(args.where, {
935
+ params,
936
+ onPath: this.trackPath,
937
+ columns: this.columns
938
+ });
939
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
940
+ const now = Date.now();
941
+ const recorder = this.recorder;
942
+ if (row) {
943
+ const current = stripSystem(this.rowToDoc(row));
944
+ const updated = stripSystem(applyUpdate(current, args.update));
945
+ const { setSql, values } = this.buildUpdateSet(updated, now);
946
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
947
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
948
+ return {
949
+ ...updated,
950
+ _id: row._id,
951
+ created_at: row.created_at,
952
+ updated_at: now
953
+ };
954
+ }
955
+ const ins = this.buildInsert(args.create);
956
+ this.db.prepare(this.insertSql()).run(...ins.values);
957
+ recorder?.recordLocal(this.name, ins._id, "upsert", ins.created_at);
958
+ return ins.returned;
959
+ })
960
+ );
642
961
  }
643
962
  /* ----------------------------- delete ----------------------------- */
644
963
  runDelete(where, single) {
645
964
  this.ensureTable();
646
965
  const params = [];
647
- const clause = buildWhere(where, { params, onPath: this.trackPath });
648
- let selectSql = `SELECT ${SELECT_COLS} FROM "${this.name}" WHERE ${clause}`;
966
+ const clause = buildWhere(where, {
967
+ params,
968
+ onPath: this.trackPath,
969
+ columns: this.columns
970
+ });
971
+ let selectSql = `SELECT * FROM "${this.name}" WHERE ${clause}`;
649
972
  if (single) selectSql += " LIMIT 1";
650
973
  const rows = this.db.prepare(selectSql).all(...params);
651
974
  if (!rows.length) return [];
652
975
  const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
653
- this.db.transaction(() => {
654
- for (const row of rows) stmt.run(row._id);
655
- });
976
+ const recorder = this.recorder;
977
+ const now = Date.now();
978
+ this.guard(
979
+ () => this.db.transaction(() => {
980
+ for (const row of rows) {
981
+ stmt.run(row._id);
982
+ recorder?.recordLocal(this.name, row._id, "delete", now);
983
+ }
984
+ })
985
+ );
656
986
  return rows.map((r) => this.rowToDoc(r));
657
987
  }
658
988
  async delete(args) {
@@ -664,16 +994,20 @@ var Collection = class {
664
994
  /* --------------------------- aggregation -------------------------- */
665
995
  async aggregate(args = {}) {
666
996
  this.ensureTable();
667
- return aggregate(
668
- { db: this.db, table: this.name, onPath: this.trackPath },
669
- args
997
+ return this.guard(
998
+ () => aggregate(
999
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1000
+ args
1001
+ )
670
1002
  );
671
1003
  }
672
1004
  async groupBy(args) {
673
1005
  this.ensureTable();
674
- return groupBy(
675
- { db: this.db, table: this.name, onPath: this.trackPath },
676
- args
1006
+ return this.guard(
1007
+ () => groupBy(
1008
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1009
+ args
1010
+ )
677
1011
  );
678
1012
  }
679
1013
  };
@@ -731,15 +1065,19 @@ var AutoIndexer = class {
731
1065
  };
732
1066
 
733
1067
  // src/driver/better-sqlite3.ts
1068
+ var STMT_CACHE_MAX = 256;
734
1069
  var BetterSqlite3Driver = class {
735
1070
  name = "better-sqlite3";
736
1071
  raw;
737
1072
  verbose;
1073
+ cache = /* @__PURE__ */ new Map();
738
1074
  constructor(BetterSqlite3, filename, options) {
739
1075
  this.verbose = options.verbose;
740
1076
  this.raw = new BetterSqlite3(filename, {
741
1077
  readonly: options.readonly ?? false
742
1078
  });
1079
+ this.raw.pragma("foreign_keys = ON");
1080
+ this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
743
1081
  if (!options.readonly && (options.wal ?? true)) {
744
1082
  this.raw.pragma("journal_mode = WAL");
745
1083
  }
@@ -750,21 +1088,35 @@ var BetterSqlite3Driver = class {
750
1088
  }
751
1089
  prepare(sql) {
752
1090
  this.verbose?.(sql);
753
- return this.raw.prepare(sql);
1091
+ const cached = this.cache.get(sql);
1092
+ if (cached) return cached;
1093
+ const stmt = this.raw.prepare(sql);
1094
+ this.cacheStmt(sql, stmt);
1095
+ return stmt;
1096
+ }
1097
+ cacheStmt(sql, stmt) {
1098
+ if (this.cache.size >= STMT_CACHE_MAX) {
1099
+ const oldest = this.cache.keys().next().value;
1100
+ if (oldest !== void 0) this.cache.delete(oldest);
1101
+ }
1102
+ this.cache.set(sql, stmt);
754
1103
  }
755
1104
  transaction(fn) {
756
1105
  return this.raw.transaction(fn)();
757
1106
  }
758
1107
  close() {
1108
+ this.cache.clear();
759
1109
  this.raw.close();
760
1110
  }
761
1111
  };
762
1112
 
763
1113
  // src/driver/node-sqlite.ts
1114
+ var STMT_CACHE_MAX2 = 256;
764
1115
  var NodeSqliteDriver = class {
765
1116
  name = "node:sqlite";
766
1117
  raw;
767
1118
  verbose;
1119
+ cache = /* @__PURE__ */ new Map();
768
1120
  depth = 0;
769
1121
  constructor(nodeSqlite, filename, options) {
770
1122
  this.verbose = options.verbose;
@@ -772,6 +1124,7 @@ var NodeSqliteDriver = class {
772
1124
  this.raw = new DatabaseSync(filename, {
773
1125
  readOnly: options.readonly ?? false
774
1126
  });
1127
+ this.raw.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
775
1128
  if (!options.readonly && (options.wal ?? true)) {
776
1129
  this.raw.exec("PRAGMA journal_mode = WAL");
777
1130
  }
@@ -782,12 +1135,20 @@ var NodeSqliteDriver = class {
782
1135
  }
783
1136
  prepare(sql) {
784
1137
  this.verbose?.(sql);
1138
+ const cached = this.cache.get(sql);
1139
+ if (cached) return cached;
785
1140
  const stmt = this.raw.prepare(sql);
786
- return {
1141
+ const wrapped = {
787
1142
  run: (...p) => stmt.run(...p),
788
1143
  get: (...p) => stmt.get(...p),
789
1144
  all: (...p) => stmt.all(...p)
790
1145
  };
1146
+ if (this.cache.size >= STMT_CACHE_MAX2) {
1147
+ const oldest = this.cache.keys().next().value;
1148
+ if (oldest !== void 0) this.cache.delete(oldest);
1149
+ }
1150
+ this.cache.set(sql, wrapped);
1151
+ return wrapped;
791
1152
  }
792
1153
  transaction(fn) {
793
1154
  const savepoint = `monlite_sp_${this.depth}`;
@@ -802,12 +1163,21 @@ var NodeSqliteDriver = class {
802
1163
  return result;
803
1164
  } catch (err) {
804
1165
  this.depth--;
805
- if (this.depth === 0) this.raw.exec("ROLLBACK");
806
- else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1166
+ try {
1167
+ if (this.depth === 0) this.raw.exec("ROLLBACK");
1168
+ else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1169
+ } catch {
1170
+ this.depth = 0;
1171
+ try {
1172
+ this.raw.exec("ROLLBACK");
1173
+ } catch {
1174
+ }
1175
+ }
807
1176
  throw err;
808
1177
  }
809
1178
  }
810
1179
  close() {
1180
+ this.cache.clear();
811
1181
  this.raw.close();
812
1182
  }
813
1183
  };
@@ -858,6 +1228,360 @@ function createDriver(filename, options = {}) {
858
1228
  );
859
1229
  }
860
1230
 
1231
+ // src/sync/version.ts
1232
+ var TS_WIDTH = 15;
1233
+ var SEQ_WIDTH = 12;
1234
+ function makeVersion(ts, nodeId, seq) {
1235
+ const base = String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1236
+ return seq === void 0 ? base : base + ":" + String(seq).padStart(SEQ_WIDTH, "0");
1237
+ }
1238
+ function compareVersions(a, b) {
1239
+ return a < b ? -1 : a > b ? 1 : 0;
1240
+ }
1241
+ function versionTs(v) {
1242
+ const i = v.indexOf(":");
1243
+ return Number(i === -1 ? v : v.slice(0, i));
1244
+ }
1245
+
1246
+ // src/sync/store.ts
1247
+ var NAME_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
1248
+ function assertName(name) {
1249
+ if (!NAME_RE2.test(name)) throw new Error(`Invalid collection name "${name}"`);
1250
+ }
1251
+ function stripSystem2(obj) {
1252
+ const { _id, created_at, updated_at, ...rest } = obj;
1253
+ return rest;
1254
+ }
1255
+ var SyncStore = class {
1256
+ constructor(db, nodeId) {
1257
+ this.db = db;
1258
+ this.init();
1259
+ this.nodeId = this.resolveNodeId(nodeId);
1260
+ }
1261
+ db;
1262
+ nodeId;
1263
+ versionSeq = 0;
1264
+ init() {
1265
+ this.db.exec(`
1266
+ CREATE TABLE IF NOT EXISTS _monlite_changes (
1267
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
1268
+ coll TEXT NOT NULL,
1269
+ doc_id TEXT NOT NULL,
1270
+ op TEXT NOT NULL,
1271
+ version TEXT NOT NULL,
1272
+ ts INTEGER NOT NULL,
1273
+ source TEXT NOT NULL DEFAULT 'local',
1274
+ pushed INTEGER NOT NULL DEFAULT 0
1275
+ );
1276
+ CREATE INDEX IF NOT EXISTS _idx_changes_doc ON _monlite_changes(coll, doc_id, seq);
1277
+ CREATE INDEX IF NOT EXISTS _idx_changes_push ON _monlite_changes(source, pushed, seq);
1278
+ CREATE TABLE IF NOT EXISTS _monlite_sync_state (
1279
+ remote TEXT PRIMARY KEY,
1280
+ cursor TEXT,
1281
+ last_pull_at INTEGER,
1282
+ last_push_seq INTEGER,
1283
+ last_push_at INTEGER
1284
+ );
1285
+ CREATE TABLE IF NOT EXISTS _monlite_conflicts (
1286
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1287
+ coll TEXT, doc_id TEXT,
1288
+ local_version TEXT, remote_version TEXT,
1289
+ winner TEXT, ts INTEGER
1290
+ );
1291
+ CREATE TABLE IF NOT EXISTS _monlite_meta (key TEXT PRIMARY KEY, value TEXT);
1292
+ `);
1293
+ }
1294
+ resolveNodeId(explicit) {
1295
+ if (explicit) {
1296
+ this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
1297
+ return explicit;
1298
+ }
1299
+ const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
1300
+ if (row?.value) return row.value;
1301
+ const generated = objectId();
1302
+ this.db.prepare(`INSERT INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(generated);
1303
+ return generated;
1304
+ }
1305
+ /** True if this database tracks sync metadata (always, once constructed). */
1306
+ get enabled() {
1307
+ return true;
1308
+ }
1309
+ /* ----------------------- local change recording ----------------------- */
1310
+ /** Append a locally-originated change to the feed. Call inside a write txn. */
1311
+ recordLocal(collection, id, op, ts) {
1312
+ const version = makeVersion(ts, this.nodeId, this.versionSeq++);
1313
+ this.db.prepare(
1314
+ `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
1315
+ VALUES (?, ?, ?, ?, ?, 'local', 0)`
1316
+ ).run(collection, id, op, version, ts);
1317
+ return version;
1318
+ }
1319
+ /** Current (latest) version of a document, or null if never recorded. */
1320
+ currentVersion(collection, id) {
1321
+ const row = this.db.prepare(
1322
+ `SELECT version FROM _monlite_changes
1323
+ WHERE coll = ? AND doc_id = ? ORDER BY seq DESC LIMIT 1`
1324
+ ).get(collection, id);
1325
+ return row?.version ?? null;
1326
+ }
1327
+ /* ----------------------------- push side ----------------------------- */
1328
+ /** Latest unpushed local change per document (the push queue). */
1329
+ pending(collections, limit) {
1330
+ const params = [];
1331
+ let collFilter = "";
1332
+ if (collections && collections.length) {
1333
+ collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1334
+ params.push(...collections);
1335
+ }
1336
+ let limitClause = "";
1337
+ if (limit != null && limit > 0) {
1338
+ limitClause = " LIMIT ?";
1339
+ params.push(limit);
1340
+ }
1341
+ const rows = this.db.prepare(
1342
+ `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1343
+ FROM _monlite_changes c
1344
+ JOIN (
1345
+ SELECT coll, doc_id, MAX(seq) AS mseq
1346
+ FROM _monlite_changes
1347
+ WHERE source = 'local' AND pushed = 0${collFilter}
1348
+ GROUP BY coll, doc_id
1349
+ ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1350
+ ORDER BY c.seq${limitClause}`
1351
+ ).all(...params);
1352
+ return rows.map((r) => {
1353
+ const change = {
1354
+ seq: r.seq,
1355
+ collection: r.coll,
1356
+ _id: r.doc_id,
1357
+ op: r.op,
1358
+ version: r.version,
1359
+ ts: r.ts
1360
+ };
1361
+ if (r.op === "upsert") {
1362
+ const doc = this.readDoc(r.coll, r.doc_id);
1363
+ if (doc) change.doc = doc;
1364
+ else change.op = "delete";
1365
+ }
1366
+ return change;
1367
+ });
1368
+ }
1369
+ /** Mark the given changes (and any earlier local rows per doc) as pushed. */
1370
+ markPushed(changes) {
1371
+ if (!changes.length) return;
1372
+ const stmt = this.db.prepare(
1373
+ `UPDATE _monlite_changes SET pushed = 1
1374
+ WHERE coll = ? AND doc_id = ? AND seq <= ? AND source = 'local'`
1375
+ );
1376
+ this.db.transaction(() => {
1377
+ for (const c of changes) stmt.run(c.collection, c._id, c.seq);
1378
+ });
1379
+ }
1380
+ /* ----------------------------- pull side ----------------------------- */
1381
+ /**
1382
+ * Apply a remote change, resolving conflicts against the local version.
1383
+ * Remote-applied changes are recorded with `source='remote'` so they are
1384
+ * never pushed back (echo prevention).
1385
+ */
1386
+ applyRemote(change, resolver) {
1387
+ assertName(change.collection);
1388
+ return this.db.transaction(() => {
1389
+ const localVersion = this.currentVersion(change.collection, change._id);
1390
+ let winner;
1391
+ if (localVersion === null) {
1392
+ winner = "remote";
1393
+ } else if (change.version === localVersion) {
1394
+ return { applied: false, conflict: false, winner: "none" };
1395
+ } else {
1396
+ winner = resolver ? resolver({
1397
+ collection: change.collection,
1398
+ _id: change._id,
1399
+ local: { version: localVersion },
1400
+ remote: { version: change.version, doc: change.doc }
1401
+ }) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
1402
+ this.recordConflict(
1403
+ change.collection,
1404
+ change._id,
1405
+ localVersion,
1406
+ change.version,
1407
+ winner
1408
+ );
1409
+ }
1410
+ if (winner !== "remote") {
1411
+ return { applied: false, conflict: localVersion !== null, winner };
1412
+ }
1413
+ this.applyData(change);
1414
+ this.db.prepare(
1415
+ `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
1416
+ VALUES (?, ?, ?, ?, ?, 'remote', 1)`
1417
+ ).run(
1418
+ change.collection,
1419
+ change._id,
1420
+ change.op,
1421
+ change.version,
1422
+ versionTs(change.version)
1423
+ );
1424
+ return { applied: true, conflict: localVersion !== null, winner: "remote" };
1425
+ });
1426
+ }
1427
+ applyData(change) {
1428
+ const { collection: coll, _id, op } = change;
1429
+ this.ensureCollTable(coll);
1430
+ if (op === "delete") {
1431
+ this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
1432
+ return;
1433
+ }
1434
+ const doc = change.doc ?? {};
1435
+ const data = JSON.stringify(stripSystem2(doc));
1436
+ const ts = versionTs(change.version);
1437
+ const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
1438
+ this.db.prepare(
1439
+ `INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
1440
+ ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
1441
+ ).run(_id, data, createdAt, ts);
1442
+ }
1443
+ /**
1444
+ * Latest change per document with `seq` greater than the given watermark,
1445
+ * as RemoteChanges (used when this database acts as a sync *source*, e.g. the
1446
+ * monlite-as-remote adapter). Returns the new watermark to resume from.
1447
+ */
1448
+ changesSince(seq, collections, limit) {
1449
+ const params = [seq];
1450
+ let collFilter = "";
1451
+ if (collections && collections.length) {
1452
+ collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1453
+ params.push(...collections);
1454
+ }
1455
+ let limitClause = "";
1456
+ if (limit != null && limit > 0) {
1457
+ limitClause = " LIMIT ?";
1458
+ params.push(limit);
1459
+ }
1460
+ const rows = this.db.prepare(
1461
+ `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1462
+ FROM _monlite_changes c
1463
+ JOIN (
1464
+ SELECT coll, doc_id, MAX(seq) AS mseq
1465
+ FROM _monlite_changes
1466
+ WHERE seq > ?${collFilter}
1467
+ GROUP BY coll, doc_id
1468
+ ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1469
+ ORDER BY c.seq${limitClause}`
1470
+ ).all(...params);
1471
+ const changes = rows.map((r) => {
1472
+ const change = {
1473
+ collection: r.coll,
1474
+ _id: r.doc_id,
1475
+ op: r.op,
1476
+ version: r.version
1477
+ };
1478
+ if (r.op === "upsert") {
1479
+ const doc = this.readDoc(r.coll, r.doc_id);
1480
+ if (doc) change.doc = doc;
1481
+ else change.op = "delete";
1482
+ }
1483
+ return change;
1484
+ });
1485
+ const maxSeq = rows.length ? rows[rows.length - 1].seq : seq;
1486
+ return { changes, maxSeq };
1487
+ }
1488
+ /* ------------------------------ bootstrap ----------------------------- */
1489
+ /**
1490
+ * Enqueue existing documents (created before sync was enabled, or never
1491
+ * recorded) as local upserts so they can be pushed. Idempotent.
1492
+ */
1493
+ seed(collections) {
1494
+ let count = 0;
1495
+ this.db.transaction(() => {
1496
+ for (const coll of collections) {
1497
+ assertName(coll);
1498
+ const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
1499
+ for (const d of docs) {
1500
+ if (this.currentVersion(coll, d._id) !== null) continue;
1501
+ this.recordLocal(coll, d._id, "upsert", d.updated_at);
1502
+ count++;
1503
+ }
1504
+ }
1505
+ });
1506
+ return count;
1507
+ }
1508
+ /* ------------------------------- state -------------------------------- */
1509
+ getState(remote) {
1510
+ const row = this.db.prepare(`SELECT * FROM _monlite_sync_state WHERE remote = ?`).get(remote);
1511
+ return {
1512
+ remote,
1513
+ cursor: row?.cursor ?? null,
1514
+ lastPullAt: row?.last_pull_at ?? null,
1515
+ lastPushSeq: row?.last_push_seq ?? null,
1516
+ lastPushAt: row?.last_push_at ?? null
1517
+ };
1518
+ }
1519
+ setState(remote, patch) {
1520
+ const cur = this.getState(remote);
1521
+ const next = { ...cur, ...patch };
1522
+ this.db.prepare(
1523
+ `INSERT INTO _monlite_sync_state (remote, cursor, last_pull_at, last_push_seq, last_push_at)
1524
+ VALUES (?, ?, ?, ?, ?)
1525
+ ON CONFLICT(remote) DO UPDATE SET
1526
+ cursor = excluded.cursor,
1527
+ last_pull_at = excluded.last_pull_at,
1528
+ last_push_seq = excluded.last_push_seq,
1529
+ last_push_at = excluded.last_push_at`
1530
+ ).run(
1531
+ remote,
1532
+ next.cursor,
1533
+ next.lastPullAt,
1534
+ next.lastPushSeq,
1535
+ next.lastPushAt
1536
+ );
1537
+ }
1538
+ /* ----------------------------- conflicts ------------------------------ */
1539
+ recordConflict(coll, id, localVersion, remoteVersion, winner) {
1540
+ this.db.prepare(
1541
+ `INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
1542
+ VALUES (?, ?, ?, ?, ?, ?)`
1543
+ ).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
1544
+ }
1545
+ conflicts() {
1546
+ const rows = this.db.prepare(
1547
+ `SELECT coll, doc_id, local_version, remote_version, winner, ts
1548
+ FROM _monlite_conflicts ORDER BY id`
1549
+ ).all();
1550
+ return rows.map((r) => ({
1551
+ collection: r.coll,
1552
+ _id: r.doc_id,
1553
+ localVersion: r.local_version,
1554
+ remoteVersion: r.remote_version,
1555
+ winner: r.winner,
1556
+ ts: r.ts
1557
+ }));
1558
+ }
1559
+ /* ------------------------------ helpers ------------------------------- */
1560
+ readDoc(coll, id) {
1561
+ assertName(coll);
1562
+ const row = this.db.prepare(
1563
+ `SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
1564
+ ).get(id);
1565
+ if (!row) return null;
1566
+ const doc = JSON.parse(row.data);
1567
+ doc._id = row._id;
1568
+ doc.created_at = row.created_at;
1569
+ doc.updated_at = row.updated_at;
1570
+ return doc;
1571
+ }
1572
+ ensureCollTable(coll) {
1573
+ assertName(coll);
1574
+ this.db.exec(
1575
+ `CREATE TABLE IF NOT EXISTS "${coll}" (
1576
+ _id TEXT PRIMARY KEY,
1577
+ data TEXT NOT NULL,
1578
+ created_at INTEGER NOT NULL,
1579
+ updated_at INTEGER NOT NULL
1580
+ )`
1581
+ );
1582
+ }
1583
+ };
1584
+
861
1585
  // src/db.ts
862
1586
  function validateName(name) {
863
1587
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
@@ -883,6 +1607,8 @@ var Monlite = class {
883
1607
  driver;
884
1608
  /** @internal */
885
1609
  autoIndexer;
1610
+ /** @internal Sync metadata store; present only when `{ sync: true }`. */
1611
+ $sync;
886
1612
  collections = /* @__PURE__ */ new Map();
887
1613
  closed = false;
888
1614
  constructor(filename, options = {}) {
@@ -890,6 +1616,7 @@ var Monlite = class {
890
1616
  driver: options.driver,
891
1617
  readonly: options.readonly,
892
1618
  wal: options.wal,
1619
+ busyTimeout: options.busyTimeout,
893
1620
  verbose: options.verbose
894
1621
  });
895
1622
  this.autoIndexer = new AutoIndexer(
@@ -897,6 +1624,13 @@ var Monlite = class {
897
1624
  options.autoIndex ?? true,
898
1625
  options.autoIndexAfter ?? 10
899
1626
  );
1627
+ if (options.sync) {
1628
+ this.$sync = new SyncStore(this.driver, options.nodeId);
1629
+ }
1630
+ }
1631
+ /** Stable node id for LWW tie-breaking (only when sync is enabled). */
1632
+ get nodeId() {
1633
+ return this.$sync?.nodeId;
900
1634
  }
901
1635
  /** The underlying native database handle (escape hatch). */
902
1636
  get sqlite() {
@@ -906,17 +1640,44 @@ var Monlite = class {
906
1640
  get driverName() {
907
1641
  return this.driver.name;
908
1642
  }
909
- /** Get (or lazily create) a typed collection handle. */
910
- collection(name) {
1643
+ /**
1644
+ * Get (or lazily create) a typed collection handle. Pass `{ schema }` to make
1645
+ * it a structured collection backed by native SQL columns; omit for the
1646
+ * default schema-free document mode. Options apply only on first access.
1647
+ */
1648
+ collection(name, options) {
911
1649
  this.assertOpen();
912
1650
  validateName(name);
913
1651
  let col = this.collections.get(name);
914
1652
  if (!col) {
915
- col = new Collection(this, name);
1653
+ col = new Collection(this, name, options);
916
1654
  this.collections.set(name, col);
1655
+ } else if (options?.schema) {
1656
+ const requested = Object.keys(options.schema);
1657
+ const existing = new Set(col.columnNames);
1658
+ const sameShape = col.mode === "structured" && requested.length === existing.size && requested.every((c) => existing.has(c));
1659
+ if (!sameShape) {
1660
+ throw new MonliteError(
1661
+ `Collection "${name}" was already opened with a different schema/mode. A collection's storage mode is fixed on first access.`
1662
+ );
1663
+ }
917
1664
  }
918
1665
  return col;
919
1666
  }
1667
+ /** Inspect a collection's physical columns (PRAGMA table_info). */
1668
+ $schema(name) {
1669
+ this.assertOpen();
1670
+ validateName(name);
1671
+ const rows = this.driver.prepare(`PRAGMA table_info("${name}")`).all();
1672
+ return Promise.resolve(
1673
+ rows.map((r) => ({
1674
+ name: r.name,
1675
+ type: r.type,
1676
+ notNull: !!r.notnull,
1677
+ primaryKey: !!r.pk
1678
+ }))
1679
+ );
1680
+ }
920
1681
  /** Tagged-template SQL query returning rows. Values are safely parameterized. */
921
1682
  $queryRaw(strings, ...values) {
922
1683
  this.assertOpen();
@@ -934,7 +1695,11 @@ var Monlite = class {
934
1695
  $executeRaw(strings, ...values) {
935
1696
  this.assertOpen();
936
1697
  const { sql, params } = buildTagged(strings, values);
937
- return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1698
+ try {
1699
+ return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1700
+ } catch (err) {
1701
+ throw normalizeDriverError(err);
1702
+ }
938
1703
  }
939
1704
  /** Like {@link $executeRaw} but takes a raw SQL string and positional params. */
940
1705
  $executeRawUnsafe(sql, ...params) {
@@ -956,7 +1721,9 @@ var Monlite = class {
956
1721
  this.assertOpen();
957
1722
  const rows = this.driver.prepare(
958
1723
  `SELECT name FROM sqlite_master
959
- WHERE type='table' AND name NOT LIKE 'sqlite_%'
1724
+ WHERE type='table'
1725
+ AND name NOT LIKE 'sqlite_%'
1726
+ AND name NOT LIKE '\\_monlite\\_%' ESCAPE '\\'
960
1727
  ORDER BY name`
961
1728
  ).all();
962
1729
  return Promise.resolve(rows.map((r) => r.name));
@@ -992,10 +1759,19 @@ function createDb(filename, options) {
992
1759
 
993
1760
  exports.Collection = Collection;
994
1761
  exports.Monlite = Monlite;
1762
+ exports.MonliteConstraintError = MonliteConstraintError;
995
1763
  exports.MonliteError = MonliteError;
1764
+ exports.MonliteForeignKeyError = MonliteForeignKeyError;
1765
+ exports.MonliteNotNullError = MonliteNotNullError;
996
1766
  exports.MonliteQueryError = MonliteQueryError;
1767
+ exports.MonliteUniqueConstraintError = MonliteUniqueConstraintError;
1768
+ exports.SyncStore = SyncStore;
1769
+ exports.compareVersions = compareVersions;
997
1770
  exports.createDb = createDb;
998
1771
  exports.isObjectId = isObjectId;
1772
+ exports.makeVersion = makeVersion;
1773
+ exports.normalizeDriverError = normalizeDriverError;
999
1774
  exports.objectId = objectId;
1775
+ exports.versionTs = versionTs;
1000
1776
  //# sourceMappingURL=index.cjs.map
1001
1777
  //# sourceMappingURL=index.cjs.map