@monlite/core 0.5.0 → 0.7.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,17 +22,64 @@ 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"]);
@@ -59,9 +106,12 @@ function pathLiteral(field) {
59
106
  return "'" + jsonPath(field).replace(/'/g, "''") + "'";
60
107
  }
61
108
  function fieldExpr(field, columns) {
62
- if (isColumn(field, columns)) return `"${field}"`;
109
+ if (isColumn(field, columns)) return quoteIdent(field);
63
110
  return `json_extract(data, ${pathLiteral(field)})`;
64
111
  }
112
+ function quoteIdent(name) {
113
+ return `"${name.replace(/"/g, '""')}"`;
114
+ }
65
115
  function bindable(value) {
66
116
  if (value === void 0 || value === null) return null;
67
117
  if (typeof value === "boolean") return value ? 1 : 0;
@@ -112,10 +162,11 @@ function translateField(field, condition, ctx) {
112
162
  return eqExpr(expr, condition, ctx);
113
163
  }
114
164
  const filter = condition;
165
+ const ci = filter.mode === "insensitive";
115
166
  const clauses = [];
116
167
  for (const op of Object.keys(filter)) {
117
168
  const v = filter[op];
118
- if (v === void 0) continue;
169
+ if (v === void 0 || op === "mode") continue;
119
170
  switch (op) {
120
171
  case "equals":
121
172
  clauses.push(eqExpr(expr, v, ctx));
@@ -142,16 +193,20 @@ function translateField(field, condition, ctx) {
142
193
  clauses.push(inExpr(expr, v, ctx, true));
143
194
  break;
144
195
  case "contains":
145
- clauses.push(containsExpr(field, expr, v, ctx));
196
+ clauses.push(containsExpr(field, expr, v, ctx, ci));
146
197
  break;
147
198
  case "startsWith":
148
199
  ctx.params.push(bindable(v));
149
- clauses.push(`instr(${expr}, ?) = 1`);
200
+ clauses.push(
201
+ ci ? `instr(lower(${expr}), lower(?)) = 1` : `instr(${expr}, ?) = 1`
202
+ );
150
203
  break;
151
204
  case "endsWith":
152
205
  ctx.params.push(bindable(v));
153
206
  ctx.params.push(bindable(v));
154
- clauses.push(`substr(${expr}, -length(?)) = ?`);
207
+ clauses.push(
208
+ ci ? `substr(lower(${expr}), -length(?)) = lower(?)` : `substr(${expr}, -length(?)) = ?`
209
+ );
155
210
  break;
156
211
  case "has":
157
212
  clauses.push(hasExpr(field, expr, v, ctx));
@@ -184,9 +239,7 @@ function cmp(expr, op, v, ctx) {
184
239
  }
185
240
  function inExpr(expr, arr, ctx, negate) {
186
241
  if (!Array.isArray(arr)) {
187
- throw new MonliteQueryError(
188
- `${negate ? "notIn" : "in"} expects an array`
189
- );
242
+ throw new MonliteQueryError(`${negate ? "notIn" : "in"} expects an array`);
190
243
  }
191
244
  if (arr.length === 0) return negate ? "1" : "0";
192
245
  const placeholders = arr.map((v) => {
@@ -195,15 +248,17 @@ function inExpr(expr, arr, ctx, negate) {
195
248
  }).join(", ");
196
249
  return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
197
250
  }
198
- function containsExpr(field, expr, v, ctx) {
251
+ function containsExpr(field, expr, v, ctx, ci) {
252
+ const sub = ci ? `instr(lower(${expr}), lower(?)) > 0` : `instr(${expr}, ?) > 0`;
199
253
  if (isColumn(field, ctx.columns)) {
200
254
  ctx.params.push(bindable(v));
201
- return `instr(${expr}, ?) > 0`;
255
+ return sub;
202
256
  }
203
257
  const path = pathLiteral(field);
258
+ const member = ci ? `lower(value) = lower(?)` : `value = ?`;
204
259
  ctx.params.push(bindable(v));
205
260
  ctx.params.push(bindable(v));
206
- return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE value = ?) ELSE instr(${expr}, ?) > 0 END)`;
261
+ return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE ${member}) ELSE ${sub} END)`;
207
262
  }
208
263
  function hasExpr(field, expr, v, ctx) {
209
264
  ctx.params.push(bindable(v));
@@ -236,16 +291,26 @@ function buildOrderBy(orderBy, onPath, columns) {
236
291
  }
237
292
 
238
293
  // src/query/path.ts
294
+ var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
295
+ function safeSegments(path) {
296
+ const segs = path.split(".");
297
+ for (const seg of segs) {
298
+ if (FORBIDDEN.has(seg)) {
299
+ throw new MonliteQueryError(`Illegal path segment "${seg}" in "${path}"`);
300
+ }
301
+ }
302
+ return segs;
303
+ }
239
304
  function getPath(obj, path) {
240
305
  let cur = obj;
241
- for (const seg of path.split(".")) {
306
+ for (const seg of safeSegments(path)) {
242
307
  if (cur == null) return void 0;
243
308
  cur = cur[seg];
244
309
  }
245
310
  return cur;
246
311
  }
247
312
  function setPath(obj, path, value) {
248
- const segs = path.split(".");
313
+ const segs = safeSegments(path);
249
314
  let cur = obj;
250
315
  for (let i = 0; i < segs.length - 1; i++) {
251
316
  const seg = segs[i];
@@ -255,7 +320,7 @@ function setPath(obj, path, value) {
255
320
  cur[segs[segs.length - 1]] = value;
256
321
  }
257
322
  function unsetPath(obj, path) {
258
- const segs = path.split(".");
323
+ const segs = safeSegments(path);
259
324
  let cur = obj;
260
325
  for (let i = 0; i < segs.length - 1; i++) {
261
326
  const seg = segs[i];
@@ -304,12 +369,18 @@ function applyUpdate(doc, data) {
304
369
  }
305
370
  const ops = data;
306
371
  if (ops.$set) {
307
- for (const [path, value] of Object.entries(ops.$set)) setPath(next, path, value);
372
+ for (const [path, value] of Object.entries(ops.$set))
373
+ setPath(next, path, value);
308
374
  }
309
375
  if (ops.$inc) {
310
376
  for (const [path, by] of Object.entries(ops.$inc)) {
377
+ if (typeof by !== "number" || !Number.isFinite(by)) {
378
+ throw new MonliteQueryError(
379
+ `$inc on "${path}" requires a finite number, got ${JSON.stringify(by)}`
380
+ );
381
+ }
311
382
  const cur = getPath(next, path);
312
- setPath(next, path, (typeof cur === "number" ? cur : 0) + Number(by));
383
+ setPath(next, path, (typeof cur === "number" ? cur : 0) + by);
313
384
  }
314
385
  }
315
386
  if (ops.$push) {
@@ -420,7 +491,11 @@ function buildHaving(having, params, columns) {
420
491
  if (!selection) continue;
421
492
  for (const field of Object.keys(selection)) {
422
493
  parts.push(
423
- ...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
494
+ ...comparisonSql(
495
+ `${fn}(${fieldExpr(field, columns)})`,
496
+ selection[field],
497
+ params
498
+ )
424
499
  );
425
500
  }
426
501
  }
@@ -428,7 +503,7 @@ function buildHaving(having, params, columns) {
428
503
  }
429
504
  function groupBy(ctx, args) {
430
505
  if (!Array.isArray(args.by) || args.by.length === 0) {
431
- throw new Error("groupBy requires a non-empty `by` array");
506
+ throw new MonliteQueryError("groupBy requires a non-empty `by` array");
432
507
  }
433
508
  const params = [];
434
509
  const where = buildWhere(args.where, {
@@ -437,15 +512,22 @@ function groupBy(ctx, args) {
437
512
  columns: ctx.columns
438
513
  });
439
514
  const groupExprs = [];
515
+ const groupCols = [];
440
516
  const selects = [];
441
- for (const field of args.by) {
517
+ args.by.forEach((field, gi) => {
442
518
  if (!isColumn(field, ctx.columns)) ctx.onPath(field);
443
519
  const expr = fieldExpr(field, ctx.columns);
520
+ const alias = `grp_${gi}`;
444
521
  groupExprs.push(expr);
445
- selects.push(`${expr} AS "${field}"`);
446
- }
522
+ selects.push(`${expr} AS ${alias}`);
523
+ groupCols.push({ alias, field });
524
+ });
447
525
  selects.push(`COUNT(*) AS agg_count`);
448
- const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
526
+ const { selects: accSelects, cols } = buildAccumulators(
527
+ args,
528
+ ctx.onPath,
529
+ ctx.columns
530
+ );
449
531
  selects.push(...accSelects);
450
532
  let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
451
533
  if (args.having) {
@@ -472,7 +554,7 @@ function groupBy(ctx, args) {
472
554
  const rows = ctx.db.prepare(sql).all(...params);
473
555
  return rows.map((row) => {
474
556
  const out = {};
475
- for (const field of args.by) out[field] = row[field];
557
+ for (const { alias, field } of groupCols) out[field] = row[alias];
476
558
  if (args._count) out._count = row.agg_count;
477
559
  for (const col of cols) {
478
560
  (out[col.kind] ??= {})[col.field] = row[col.alias] ?? null;
@@ -511,6 +593,13 @@ var Collection = class {
511
593
  );
512
594
  }
513
595
  const normalized = typeof def === "string" ? { type: def } : def;
596
+ if (normalized.references && !/^[A-Za-z_][A-Za-z0-9_]*(\([A-Za-z_][A-Za-z0-9_]*\))?$/.test(
597
+ normalized.references
598
+ )) {
599
+ throw new MonliteError(
600
+ `Invalid references "${normalized.references}" on column "${field}"`
601
+ );
602
+ }
514
603
  this.columnDefs[field] = normalized;
515
604
  this.columnOrder.push(field);
516
605
  this.columns.add(field);
@@ -532,6 +621,14 @@ var Collection = class {
532
621
  get db() {
533
622
  return this.mon.driver;
534
623
  }
624
+ /** Run a DB operation, normalizing driver errors into typed MonliteErrors. */
625
+ guard(fn) {
626
+ try {
627
+ return fn();
628
+ } catch (err) {
629
+ throw normalizeDriverError(err, this.name);
630
+ }
631
+ }
535
632
  /** Native column names declared for this collection (structured mode). */
536
633
  get columnNames() {
537
634
  return [...this.columnOrder];
@@ -559,7 +656,8 @@ var Collection = class {
559
656
  let line = `"${field}" ${sqliteType(def.type)}`;
560
657
  if (def.notNull) line += " NOT NULL";
561
658
  if (def.unique) line += " UNIQUE";
562
- if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
659
+ if (def.default !== void 0)
660
+ line += ` DEFAULT ${formatDefault(def.default)}`;
563
661
  if (def.references) line += ` REFERENCES ${def.references}`;
564
662
  lines.push(line);
565
663
  }
@@ -584,7 +682,11 @@ var Collection = class {
584
682
  if (this.mode === "structured") {
585
683
  for (const field of this.columnOrder) {
586
684
  const value = row[field];
587
- if (value === null || value === void 0) continue;
685
+ if (value === void 0) continue;
686
+ if (value === null) {
687
+ doc[field] = null;
688
+ continue;
689
+ }
588
690
  doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
589
691
  }
590
692
  }
@@ -597,6 +699,11 @@ var Collection = class {
597
699
  if (this.jsonColumns.has(field)) {
598
700
  return value === void 0 ? null : JSON.stringify(value);
599
701
  }
702
+ if (value !== null && typeof value === "object" && !(value instanceof Date) && !Buffer.isBuffer(value)) {
703
+ throw new MonliteQueryError(
704
+ `Column "${field}" cannot store an object/array. Declare it as { type: "JSON" } to store structured values.`
705
+ );
706
+ }
600
707
  return bindable(value);
601
708
  }
602
709
  insertColumns() {
@@ -614,7 +721,12 @@ var Collection = class {
614
721
  const now = Date.now();
615
722
  const id = input._id != null ? String(input._id) : objectId();
616
723
  const doc = stripSystem(input);
617
- const returned = { ...doc, _id: id, created_at: now, updated_at: now };
724
+ const returned = {
725
+ ...doc,
726
+ _id: id,
727
+ created_at: now,
728
+ updated_at: now
729
+ };
618
730
  if (this.mode === "document") {
619
731
  return {
620
732
  _id: id,
@@ -666,9 +778,65 @@ var Collection = class {
666
778
  ];
667
779
  return { setSql: setParts.join(", "), values };
668
780
  }
669
- /** Sync store, but only for document collections (structured sync is future work). */
781
+ /** Sync store for recording local changes (both document and structured). */
670
782
  get recorder() {
671
- return this.mode === "document" ? this.mon.$sync : void 0;
783
+ return this.mon.$sync;
784
+ }
785
+ /** @internal Read a full document by id (mode-aware), synchronously. */
786
+ getRaw(id) {
787
+ this.ensureTable();
788
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
789
+ return row ? this.rowToDoc(row) : null;
790
+ }
791
+ /**
792
+ * @internal Apply a remote change to storage WITHOUT recording it to the
793
+ * change feed (the sync store records the `remote` feed row itself). Used by
794
+ * `@monlite/sync` so structured collections sync correctly through the same
795
+ * column/overflow split as local writes.
796
+ */
797
+ applyRemoteWrite(op, id, doc, ts) {
798
+ this.ensureTable();
799
+ if (op === "delete") {
800
+ this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`).run(id);
801
+ return;
802
+ }
803
+ const clean = stripSystem(doc ?? {});
804
+ const createdAt = typeof doc?.created_at === "number" ? doc.created_at : ts;
805
+ if (this.mode === "document") {
806
+ this.db.prepare(
807
+ `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
808
+ ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
809
+ ).run(id, JSON.stringify(clean), createdAt, ts);
810
+ return;
811
+ }
812
+ const overflow = {};
813
+ const colValues = {};
814
+ for (const [k, v] of Object.entries(clean)) {
815
+ if (this.columns.has(k)) colValues[k] = v;
816
+ else overflow[k] = v;
817
+ }
818
+ const cols = [
819
+ "_id",
820
+ "created_at",
821
+ "updated_at",
822
+ "data",
823
+ ...this.columnOrder
824
+ ];
825
+ const values = [
826
+ id,
827
+ createdAt,
828
+ ts,
829
+ JSON.stringify(overflow),
830
+ ...this.columnOrder.map(
831
+ (c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
832
+ )
833
+ ];
834
+ const colList = cols.map((c) => `"${c}"`).join(", ");
835
+ const placeholders = cols.map(() => "?").join(", ");
836
+ const updateSet = cols.filter((c) => c !== "_id" && c !== "created_at").map((c) => `"${c}" = excluded."${c}"`).join(", ");
837
+ this.db.prepare(
838
+ `INSERT INTO "${this.name}" (${colList}) VALUES (${placeholders}) ON CONFLICT(_id) DO UPDATE SET ${updateSet}`
839
+ ).run(...values);
672
840
  }
673
841
  /* ----------------------------- create ----------------------------- */
674
842
  async create(args) {
@@ -679,21 +847,22 @@ var Collection = class {
679
847
  this.db.prepare(this.insertSql()).run(...row.values);
680
848
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
681
849
  };
682
- if (recorder) this.db.transaction(write);
683
- else write();
850
+ this.guard(() => recorder ? this.db.transaction(write) : write());
684
851
  return row.returned;
685
852
  }
686
853
  async createMany(args) {
687
854
  this.ensureTable();
688
855
  const stmt = this.db.prepare(this.insertSql());
689
856
  const recorder = this.recorder;
690
- this.db.transaction(() => {
691
- for (const item of args.data) {
692
- const row = this.buildInsert(item);
693
- stmt.run(...row.values);
694
- recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
695
- }
696
- });
857
+ this.guard(
858
+ () => this.db.transaction(() => {
859
+ for (const item of args.data) {
860
+ const row = this.buildInsert(item);
861
+ stmt.run(...row.values);
862
+ recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
863
+ }
864
+ })
865
+ );
697
866
  return { count: args.data.length };
698
867
  }
699
868
  /* ------------------------------ read ------------------------------ */
@@ -723,6 +892,28 @@ var Collection = class {
723
892
  const rows = await this.findMany({ ...args, take: 1 });
724
893
  return rows[0] ?? null;
725
894
  }
895
+ /** Alias of {@link findFirst} for Prisma familiarity. */
896
+ async findUnique(args = {}) {
897
+ return this.findFirst(args);
898
+ }
899
+ /** Like {@link findFirst} but throws if no document matches. */
900
+ async findFirstOrThrow(args = {}) {
901
+ const doc = await this.findFirst(args);
902
+ if (!doc) throw new MonliteError(`No document found in "${this.name}"`);
903
+ return doc;
904
+ }
905
+ /** True if at least one document matches. */
906
+ async exists(where) {
907
+ this.ensureTable();
908
+ const params = [];
909
+ const clause = buildWhere(where, {
910
+ params,
911
+ onPath: this.trackPath,
912
+ columns: this.columns
913
+ });
914
+ const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
915
+ return row != null;
916
+ }
726
917
  async findById(id) {
727
918
  this.ensureTable();
728
919
  const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
@@ -758,8 +949,10 @@ var Collection = class {
758
949
  this.trackPath(field);
759
950
  sql = `SELECT DISTINCT je.value AS v FROM "${this.name}" CROSS JOIN json_each("${this.name}".data, ${pathLiteral(field)}) je WHERE ${clause} ORDER BY v`;
760
951
  }
761
- const rows = this.db.prepare(sql).all(...params);
762
- return rows.map((r) => r.v);
952
+ return this.guard(() => {
953
+ const rows = this.db.prepare(sql).all(...params);
954
+ return rows.map((r) => r.v);
955
+ });
763
956
  }
764
957
  /* ----------------------------- update ----------------------------- */
765
958
  runUpdate(where, data, single) {
@@ -776,23 +969,25 @@ var Collection = class {
776
969
  if (!rows.length) return [];
777
970
  const now = Date.now();
778
971
  const recorder = this.recorder;
779
- return this.db.transaction(() => {
780
- const out = [];
781
- for (const row of rows) {
782
- const current = stripSystem(this.rowToDoc(row));
783
- const updated = stripSystem(applyUpdate(current, data));
784
- const { setSql, values } = this.buildUpdateSet(updated, now);
785
- this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
786
- recorder?.recordLocal(this.name, row._id, "upsert", now);
787
- out.push({
788
- ...updated,
789
- _id: row._id,
790
- created_at: row.created_at,
791
- updated_at: now
792
- });
793
- }
794
- return out;
795
- });
972
+ return this.guard(
973
+ () => this.db.transaction(() => {
974
+ const out = [];
975
+ for (const row of rows) {
976
+ const current = stripSystem(this.rowToDoc(row));
977
+ const updated = stripSystem(applyUpdate(current, data));
978
+ const { setSql, values } = this.buildUpdateSet(updated, now);
979
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
980
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
981
+ out.push({
982
+ ...updated,
983
+ _id: row._id,
984
+ created_at: row.created_at,
985
+ updated_at: now
986
+ });
987
+ }
988
+ return out;
989
+ })
990
+ );
796
991
  }
797
992
  async update(args) {
798
993
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -802,15 +997,36 @@ var Collection = class {
802
997
  }
803
998
  async upsert(args) {
804
999
  this.ensureTable();
805
- const existing = await this.findFirst({ where: args.where });
806
- if (existing) {
807
- const updated = await this.update({
808
- where: { _id: existing._id },
809
- data: args.update
810
- });
811
- return updated;
812
- }
813
- return this.create({ data: args.create });
1000
+ return this.guard(
1001
+ () => this.db.transaction(() => {
1002
+ const params = [];
1003
+ const clause = buildWhere(args.where, {
1004
+ params,
1005
+ onPath: this.trackPath,
1006
+ columns: this.columns
1007
+ });
1008
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
1009
+ const now = Date.now();
1010
+ const recorder = this.recorder;
1011
+ if (row) {
1012
+ const current = stripSystem(this.rowToDoc(row));
1013
+ const updated = stripSystem(applyUpdate(current, args.update));
1014
+ const { setSql, values } = this.buildUpdateSet(updated, now);
1015
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
1016
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
1017
+ return {
1018
+ ...updated,
1019
+ _id: row._id,
1020
+ created_at: row.created_at,
1021
+ updated_at: now
1022
+ };
1023
+ }
1024
+ const ins = this.buildInsert(args.create);
1025
+ this.db.prepare(this.insertSql()).run(...ins.values);
1026
+ recorder?.recordLocal(this.name, ins._id, "upsert", ins.created_at);
1027
+ return ins.returned;
1028
+ })
1029
+ );
814
1030
  }
815
1031
  /* ----------------------------- delete ----------------------------- */
816
1032
  runDelete(where, single) {
@@ -828,12 +1044,14 @@ var Collection = class {
828
1044
  const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
829
1045
  const recorder = this.recorder;
830
1046
  const now = Date.now();
831
- this.db.transaction(() => {
832
- for (const row of rows) {
833
- stmt.run(row._id);
834
- recorder?.recordLocal(this.name, row._id, "delete", now);
835
- }
836
- });
1047
+ this.guard(
1048
+ () => this.db.transaction(() => {
1049
+ for (const row of rows) {
1050
+ stmt.run(row._id);
1051
+ recorder?.recordLocal(this.name, row._id, "delete", now);
1052
+ }
1053
+ })
1054
+ );
837
1055
  return rows.map((r) => this.rowToDoc(r));
838
1056
  }
839
1057
  async delete(args) {
@@ -845,16 +1063,30 @@ var Collection = class {
845
1063
  /* --------------------------- aggregation -------------------------- */
846
1064
  async aggregate(args = {}) {
847
1065
  this.ensureTable();
848
- return aggregate(
849
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
850
- args
1066
+ return this.guard(
1067
+ () => aggregate(
1068
+ {
1069
+ db: this.db,
1070
+ table: this.name,
1071
+ onPath: this.trackPath,
1072
+ columns: this.columns
1073
+ },
1074
+ args
1075
+ )
851
1076
  );
852
1077
  }
853
1078
  async groupBy(args) {
854
1079
  this.ensureTable();
855
- return groupBy(
856
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
857
- args
1080
+ return this.guard(
1081
+ () => groupBy(
1082
+ {
1083
+ db: this.db,
1084
+ table: this.name,
1085
+ onPath: this.trackPath,
1086
+ columns: this.columns
1087
+ },
1088
+ args
1089
+ )
858
1090
  );
859
1091
  }
860
1092
  };
@@ -912,15 +1144,19 @@ var AutoIndexer = class {
912
1144
  };
913
1145
 
914
1146
  // src/driver/better-sqlite3.ts
1147
+ var STMT_CACHE_MAX = 256;
915
1148
  var BetterSqlite3Driver = class {
916
1149
  name = "better-sqlite3";
917
1150
  raw;
918
1151
  verbose;
1152
+ cache = /* @__PURE__ */ new Map();
919
1153
  constructor(BetterSqlite3, filename, options) {
920
1154
  this.verbose = options.verbose;
921
1155
  this.raw = new BetterSqlite3(filename, {
922
1156
  readonly: options.readonly ?? false
923
1157
  });
1158
+ this.raw.pragma("foreign_keys = ON");
1159
+ this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
924
1160
  if (!options.readonly && (options.wal ?? true)) {
925
1161
  this.raw.pragma("journal_mode = WAL");
926
1162
  }
@@ -931,21 +1167,35 @@ var BetterSqlite3Driver = class {
931
1167
  }
932
1168
  prepare(sql) {
933
1169
  this.verbose?.(sql);
934
- return this.raw.prepare(sql);
1170
+ const cached = this.cache.get(sql);
1171
+ if (cached) return cached;
1172
+ const stmt = this.raw.prepare(sql);
1173
+ this.cacheStmt(sql, stmt);
1174
+ return stmt;
1175
+ }
1176
+ cacheStmt(sql, stmt) {
1177
+ if (this.cache.size >= STMT_CACHE_MAX) {
1178
+ const oldest = this.cache.keys().next().value;
1179
+ if (oldest !== void 0) this.cache.delete(oldest);
1180
+ }
1181
+ this.cache.set(sql, stmt);
935
1182
  }
936
1183
  transaction(fn) {
937
1184
  return this.raw.transaction(fn)();
938
1185
  }
939
1186
  close() {
1187
+ this.cache.clear();
940
1188
  this.raw.close();
941
1189
  }
942
1190
  };
943
1191
 
944
1192
  // src/driver/node-sqlite.ts
1193
+ var STMT_CACHE_MAX2 = 256;
945
1194
  var NodeSqliteDriver = class {
946
1195
  name = "node:sqlite";
947
1196
  raw;
948
1197
  verbose;
1198
+ cache = /* @__PURE__ */ new Map();
949
1199
  depth = 0;
950
1200
  constructor(nodeSqlite, filename, options) {
951
1201
  this.verbose = options.verbose;
@@ -953,6 +1203,7 @@ var NodeSqliteDriver = class {
953
1203
  this.raw = new DatabaseSync(filename, {
954
1204
  readOnly: options.readonly ?? false
955
1205
  });
1206
+ this.raw.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
956
1207
  if (!options.readonly && (options.wal ?? true)) {
957
1208
  this.raw.exec("PRAGMA journal_mode = WAL");
958
1209
  }
@@ -963,12 +1214,20 @@ var NodeSqliteDriver = class {
963
1214
  }
964
1215
  prepare(sql) {
965
1216
  this.verbose?.(sql);
1217
+ const cached = this.cache.get(sql);
1218
+ if (cached) return cached;
966
1219
  const stmt = this.raw.prepare(sql);
967
- return {
1220
+ const wrapped = {
968
1221
  run: (...p) => stmt.run(...p),
969
1222
  get: (...p) => stmt.get(...p),
970
1223
  all: (...p) => stmt.all(...p)
971
1224
  };
1225
+ if (this.cache.size >= STMT_CACHE_MAX2) {
1226
+ const oldest = this.cache.keys().next().value;
1227
+ if (oldest !== void 0) this.cache.delete(oldest);
1228
+ }
1229
+ this.cache.set(sql, wrapped);
1230
+ return wrapped;
972
1231
  }
973
1232
  transaction(fn) {
974
1233
  const savepoint = `monlite_sp_${this.depth}`;
@@ -983,12 +1242,21 @@ var NodeSqliteDriver = class {
983
1242
  return result;
984
1243
  } catch (err) {
985
1244
  this.depth--;
986
- if (this.depth === 0) this.raw.exec("ROLLBACK");
987
- else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1245
+ try {
1246
+ if (this.depth === 0) this.raw.exec("ROLLBACK");
1247
+ else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1248
+ } catch {
1249
+ this.depth = 0;
1250
+ try {
1251
+ this.raw.exec("ROLLBACK");
1252
+ } catch {
1253
+ }
1254
+ }
988
1255
  throw err;
989
1256
  }
990
1257
  }
991
1258
  close() {
1259
+ this.cache.clear();
992
1260
  this.raw.close();
993
1261
  }
994
1262
  };
@@ -1041,8 +1309,10 @@ function createDriver(filename, options = {}) {
1041
1309
 
1042
1310
  // src/sync/version.ts
1043
1311
  var TS_WIDTH = 15;
1044
- function makeVersion(ts, nodeId) {
1045
- return String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1312
+ var SEQ_WIDTH = 12;
1313
+ function makeVersion(ts, nodeId, seq) {
1314
+ const base = String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1315
+ return seq === void 0 ? base : base + ":" + String(seq).padStart(SEQ_WIDTH, "0");
1046
1316
  }
1047
1317
  function compareVersions(a, b) {
1048
1318
  return a < b ? -1 : a > b ? 1 : 0;
@@ -1062,13 +1332,16 @@ function stripSystem2(obj) {
1062
1332
  return rest;
1063
1333
  }
1064
1334
  var SyncStore = class {
1065
- constructor(db, nodeId) {
1335
+ constructor(db, nodeId, mon) {
1066
1336
  this.db = db;
1337
+ this.mon = mon;
1067
1338
  this.init();
1068
1339
  this.nodeId = this.resolveNodeId(nodeId);
1069
1340
  }
1070
1341
  db;
1342
+ mon;
1071
1343
  nodeId;
1344
+ versionSeq = 0;
1072
1345
  init() {
1073
1346
  this.db.exec(`
1074
1347
  CREATE TABLE IF NOT EXISTS _monlite_changes (
@@ -1101,7 +1374,9 @@ var SyncStore = class {
1101
1374
  }
1102
1375
  resolveNodeId(explicit) {
1103
1376
  if (explicit) {
1104
- this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
1377
+ this.db.prepare(
1378
+ `INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`
1379
+ ).run(explicit);
1105
1380
  return explicit;
1106
1381
  }
1107
1382
  const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
@@ -1117,7 +1392,7 @@ var SyncStore = class {
1117
1392
  /* ----------------------- local change recording ----------------------- */
1118
1393
  /** Append a locally-originated change to the feed. Call inside a write txn. */
1119
1394
  recordLocal(collection, id, op, ts) {
1120
- const version = makeVersion(ts, this.nodeId);
1395
+ const version = makeVersion(ts, this.nodeId, this.versionSeq++);
1121
1396
  this.db.prepare(
1122
1397
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
1123
1398
  VALUES (?, ?, ?, ?, ?, 'local', 0)`
@@ -1134,13 +1409,18 @@ var SyncStore = class {
1134
1409
  }
1135
1410
  /* ----------------------------- push side ----------------------------- */
1136
1411
  /** Latest unpushed local change per document (the push queue). */
1137
- pending(collections) {
1412
+ pending(collections, limit) {
1138
1413
  const params = [];
1139
1414
  let collFilter = "";
1140
1415
  if (collections && collections.length) {
1141
1416
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1142
1417
  params.push(...collections);
1143
1418
  }
1419
+ let limitClause = "";
1420
+ if (limit != null && limit > 0) {
1421
+ limitClause = " LIMIT ?";
1422
+ params.push(limit);
1423
+ }
1144
1424
  const rows = this.db.prepare(
1145
1425
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1146
1426
  FROM _monlite_changes c
@@ -1150,7 +1430,7 @@ var SyncStore = class {
1150
1430
  WHERE source = 'local' AND pushed = 0${collFilter}
1151
1431
  GROUP BY coll, doc_id
1152
1432
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1153
- ORDER BY c.seq`
1433
+ ORDER BY c.seq${limitClause}`
1154
1434
  ).all(...params);
1155
1435
  return rows.map((r) => {
1156
1436
  const change = {
@@ -1188,31 +1468,34 @@ var SyncStore = class {
1188
1468
  */
1189
1469
  applyRemote(change, resolver) {
1190
1470
  assertName(change.collection);
1191
- const localVersion = this.currentVersion(change.collection, change._id);
1192
- let winner;
1193
- if (localVersion === null) {
1194
- winner = "remote";
1195
- } else if (change.version === localVersion) {
1196
- return { applied: false, conflict: false, winner: "none" };
1197
- } else {
1198
- winner = resolver ? resolver({
1199
- collection: change.collection,
1200
- _id: change._id,
1201
- local: { version: localVersion },
1202
- remote: { version: change.version, doc: change.doc }
1203
- }) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
1204
- this.recordConflict(
1205
- change.collection,
1206
- change._id,
1207
- localVersion,
1208
- change.version,
1209
- winner
1210
- );
1211
- }
1212
- if (winner !== "remote") {
1213
- return { applied: false, conflict: localVersion !== null, winner };
1214
- }
1215
- this.db.transaction(() => {
1471
+ return this.db.transaction(() => {
1472
+ const localVersion = this.currentVersion(change.collection, change._id);
1473
+ let winner;
1474
+ if (localVersion === null) {
1475
+ winner = "remote";
1476
+ } else if (change.version === localVersion) {
1477
+ return { applied: false, conflict: false, winner: "none" };
1478
+ } else {
1479
+ winner = resolver ? resolver({
1480
+ collection: change.collection,
1481
+ _id: change._id,
1482
+ local: { version: localVersion },
1483
+ remote: { version: change.version, doc: change.doc }
1484
+ }) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
1485
+ this.recordConflict(
1486
+ change.collection,
1487
+ change._id,
1488
+ localVersion,
1489
+ change.version,
1490
+ winner
1491
+ );
1492
+ }
1493
+ if (winner !== "remote") {
1494
+ if (localVersion !== null) {
1495
+ this.recordLocal(change.collection, change._id, "upsert", Date.now());
1496
+ }
1497
+ return { applied: false, conflict: localVersion !== null, winner };
1498
+ }
1216
1499
  this.applyData(change);
1217
1500
  this.db.prepare(
1218
1501
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
@@ -1224,37 +1507,49 @@ var SyncStore = class {
1224
1507
  change.version,
1225
1508
  versionTs(change.version)
1226
1509
  );
1510
+ return {
1511
+ applied: true,
1512
+ conflict: localVersion !== null,
1513
+ winner: "remote"
1514
+ };
1227
1515
  });
1228
- return { applied: true, conflict: localVersion !== null, winner: "remote" };
1229
1516
  }
1230
1517
  applyData(change) {
1231
- const { collection: coll, _id, op } = change;
1518
+ const ts = versionTs(change.version);
1519
+ if (this.mon) {
1520
+ this.mon.collection(change.collection).applyRemoteWrite(change.op, change._id, change.doc, ts);
1521
+ return;
1522
+ }
1523
+ const coll = change.collection;
1232
1524
  this.ensureCollTable(coll);
1233
- if (op === "delete") {
1234
- this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
1525
+ if (change.op === "delete") {
1526
+ this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(change._id);
1235
1527
  return;
1236
1528
  }
1237
1529
  const doc = change.doc ?? {};
1238
- const data = JSON.stringify(stripSystem2(doc));
1239
- const ts = versionTs(change.version);
1240
1530
  const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
1241
1531
  this.db.prepare(
1242
1532
  `INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
1243
1533
  ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
1244
- ).run(_id, data, createdAt, ts);
1534
+ ).run(change._id, JSON.stringify(stripSystem2(doc)), createdAt, ts);
1245
1535
  }
1246
1536
  /**
1247
1537
  * Latest change per document with `seq` greater than the given watermark,
1248
1538
  * as RemoteChanges (used when this database acts as a sync *source*, e.g. the
1249
1539
  * monlite-as-remote adapter). Returns the new watermark to resume from.
1250
1540
  */
1251
- changesSince(seq, collections) {
1541
+ changesSince(seq, collections, limit) {
1252
1542
  const params = [seq];
1253
1543
  let collFilter = "";
1254
1544
  if (collections && collections.length) {
1255
1545
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1256
1546
  params.push(...collections);
1257
1547
  }
1548
+ let limitClause = "";
1549
+ if (limit != null && limit > 0) {
1550
+ limitClause = " LIMIT ?";
1551
+ params.push(limit);
1552
+ }
1258
1553
  const rows = this.db.prepare(
1259
1554
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1260
1555
  FROM _monlite_changes c
@@ -1264,7 +1559,7 @@ var SyncStore = class {
1264
1559
  WHERE seq > ?${collFilter}
1265
1560
  GROUP BY coll, doc_id
1266
1561
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1267
- ORDER BY c.seq`
1562
+ ORDER BY c.seq${limitClause}`
1268
1563
  ).all(...params);
1269
1564
  const changes = rows.map((r) => {
1270
1565
  const change = {
@@ -1280,8 +1575,8 @@ var SyncStore = class {
1280
1575
  }
1281
1576
  return change;
1282
1577
  });
1283
- const maxRow = this.db.prepare(`SELECT MAX(seq) AS m FROM _monlite_changes`).get();
1284
- return { changes, maxSeq: maxRow?.m ?? seq };
1578
+ const maxSeq = rows.length ? rows[rows.length - 1].seq : seq;
1579
+ return { changes, maxSeq };
1285
1580
  }
1286
1581
  /* ------------------------------ bootstrap ----------------------------- */
1287
1582
  /**
@@ -1293,6 +1588,7 @@ var SyncStore = class {
1293
1588
  this.db.transaction(() => {
1294
1589
  for (const coll of collections) {
1295
1590
  assertName(coll);
1591
+ if (!this.tableExists(coll)) continue;
1296
1592
  const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
1297
1593
  for (const d of docs) {
1298
1594
  if (this.currentVersion(coll, d._id) !== null) continue;
@@ -1338,7 +1634,14 @@ var SyncStore = class {
1338
1634
  this.db.prepare(
1339
1635
  `INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
1340
1636
  VALUES (?, ?, ?, ?, ?, ?)`
1341
- ).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
1637
+ ).run(
1638
+ coll,
1639
+ id,
1640
+ localVersion,
1641
+ remoteVersion,
1642
+ winner,
1643
+ versionTs(remoteVersion)
1644
+ );
1342
1645
  }
1343
1646
  conflicts() {
1344
1647
  const rows = this.db.prepare(
@@ -1357,6 +1660,8 @@ var SyncStore = class {
1357
1660
  /* ------------------------------ helpers ------------------------------- */
1358
1661
  readDoc(coll, id) {
1359
1662
  assertName(coll);
1663
+ if (this.mon) return this.mon.collection(coll).getRaw(id);
1664
+ if (!this.tableExists(coll)) return null;
1360
1665
  const row = this.db.prepare(
1361
1666
  `SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
1362
1667
  ).get(id);
@@ -1367,6 +1672,9 @@ var SyncStore = class {
1367
1672
  doc.updated_at = row.updated_at;
1368
1673
  return doc;
1369
1674
  }
1675
+ tableExists(name) {
1676
+ return this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?`).get(name) != null;
1677
+ }
1370
1678
  ensureCollTable(coll) {
1371
1679
  assertName(coll);
1372
1680
  this.db.exec(
@@ -1414,6 +1722,7 @@ var Monlite = class {
1414
1722
  driver: options.driver,
1415
1723
  readonly: options.readonly,
1416
1724
  wal: options.wal,
1725
+ busyTimeout: options.busyTimeout,
1417
1726
  verbose: options.verbose
1418
1727
  });
1419
1728
  this.autoIndexer = new AutoIndexer(
@@ -1422,7 +1731,7 @@ var Monlite = class {
1422
1731
  options.autoIndexAfter ?? 10
1423
1732
  );
1424
1733
  if (options.sync) {
1425
- this.$sync = new SyncStore(this.driver, options.nodeId);
1734
+ this.$sync = new SyncStore(this.driver, options.nodeId, this);
1426
1735
  }
1427
1736
  }
1428
1737
  /** Stable node id for LWW tie-breaking (only when sync is enabled). */
@@ -1449,6 +1758,15 @@ var Monlite = class {
1449
1758
  if (!col) {
1450
1759
  col = new Collection(this, name, options);
1451
1760
  this.collections.set(name, col);
1761
+ } else if (options?.schema) {
1762
+ const requested = Object.keys(options.schema);
1763
+ const existing = new Set(col.columnNames);
1764
+ const sameShape = col.mode === "structured" && requested.length === existing.size && requested.every((c) => existing.has(c));
1765
+ if (!sameShape) {
1766
+ throw new MonliteError(
1767
+ `Collection "${name}" was already opened with a different schema/mode. A collection's storage mode is fixed on first access.`
1768
+ );
1769
+ }
1452
1770
  }
1453
1771
  return col;
1454
1772
  }
@@ -1483,7 +1801,11 @@ var Monlite = class {
1483
1801
  $executeRaw(strings, ...values) {
1484
1802
  this.assertOpen();
1485
1803
  const { sql, params } = buildTagged(strings, values);
1486
- return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1804
+ try {
1805
+ return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1806
+ } catch (err) {
1807
+ throw normalizeDriverError(err);
1808
+ }
1487
1809
  }
1488
1810
  /** Like {@link $executeRaw} but takes a raw SQL string and positional params. */
1489
1811
  $executeRawUnsafe(sql, ...params) {
@@ -1543,13 +1865,18 @@ function createDb(filename, options) {
1543
1865
 
1544
1866
  exports.Collection = Collection;
1545
1867
  exports.Monlite = Monlite;
1868
+ exports.MonliteConstraintError = MonliteConstraintError;
1546
1869
  exports.MonliteError = MonliteError;
1870
+ exports.MonliteForeignKeyError = MonliteForeignKeyError;
1871
+ exports.MonliteNotNullError = MonliteNotNullError;
1547
1872
  exports.MonliteQueryError = MonliteQueryError;
1873
+ exports.MonliteUniqueConstraintError = MonliteUniqueConstraintError;
1548
1874
  exports.SyncStore = SyncStore;
1549
1875
  exports.compareVersions = compareVersions;
1550
1876
  exports.createDb = createDb;
1551
1877
  exports.isObjectId = isObjectId;
1552
1878
  exports.makeVersion = makeVersion;
1879
+ exports.normalizeDriverError = normalizeDriverError;
1553
1880
  exports.objectId = objectId;
1554
1881
  exports.versionTs = versionTs;
1555
1882
  //# sourceMappingURL=index.cjs.map