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