@monlite/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,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));
@@ -195,15 +250,17 @@ function inExpr(expr, arr, ctx, negate) {
195
250
  }).join(", ");
196
251
  return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
197
252
  }
198
- function containsExpr(field, expr, v, ctx) {
253
+ function containsExpr(field, expr, v, ctx, ci) {
254
+ const sub = ci ? `instr(lower(${expr}), lower(?)) > 0` : `instr(${expr}, ?) > 0`;
199
255
  if (isColumn(field, ctx.columns)) {
200
256
  ctx.params.push(bindable(v));
201
- return `instr(${expr}, ?) > 0`;
257
+ return sub;
202
258
  }
203
259
  const path = pathLiteral(field);
260
+ const member = ci ? `lower(value) = lower(?)` : `value = ?`;
204
261
  ctx.params.push(bindable(v));
205
262
  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)`;
263
+ return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE ${member}) ELSE ${sub} END)`;
207
264
  }
208
265
  function hasExpr(field, expr, v, ctx) {
209
266
  ctx.params.push(bindable(v));
@@ -236,16 +293,26 @@ function buildOrderBy(orderBy, onPath, columns) {
236
293
  }
237
294
 
238
295
  // src/query/path.ts
296
+ var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
297
+ function safeSegments(path) {
298
+ const segs = path.split(".");
299
+ for (const seg of segs) {
300
+ if (FORBIDDEN.has(seg)) {
301
+ throw new MonliteQueryError(`Illegal path segment "${seg}" in "${path}"`);
302
+ }
303
+ }
304
+ return segs;
305
+ }
239
306
  function getPath(obj, path) {
240
307
  let cur = obj;
241
- for (const seg of path.split(".")) {
308
+ for (const seg of safeSegments(path)) {
242
309
  if (cur == null) return void 0;
243
310
  cur = cur[seg];
244
311
  }
245
312
  return cur;
246
313
  }
247
314
  function setPath(obj, path, value) {
248
- const segs = path.split(".");
315
+ const segs = safeSegments(path);
249
316
  let cur = obj;
250
317
  for (let i = 0; i < segs.length - 1; i++) {
251
318
  const seg = segs[i];
@@ -255,7 +322,7 @@ function setPath(obj, path, value) {
255
322
  cur[segs[segs.length - 1]] = value;
256
323
  }
257
324
  function unsetPath(obj, path) {
258
- const segs = path.split(".");
325
+ const segs = safeSegments(path);
259
326
  let cur = obj;
260
327
  for (let i = 0; i < segs.length - 1; i++) {
261
328
  const seg = segs[i];
@@ -308,8 +375,13 @@ function applyUpdate(doc, data) {
308
375
  }
309
376
  if (ops.$inc) {
310
377
  for (const [path, by] of Object.entries(ops.$inc)) {
378
+ if (typeof by !== "number" || !Number.isFinite(by)) {
379
+ throw new MonliteQueryError(
380
+ `$inc on "${path}" requires a finite number, got ${JSON.stringify(by)}`
381
+ );
382
+ }
311
383
  const cur = getPath(next, path);
312
- setPath(next, path, (typeof cur === "number" ? cur : 0) + Number(by));
384
+ setPath(next, path, (typeof cur === "number" ? cur : 0) + by);
313
385
  }
314
386
  }
315
387
  if (ops.$push) {
@@ -428,7 +500,7 @@ function buildHaving(having, params, columns) {
428
500
  }
429
501
  function groupBy(ctx, args) {
430
502
  if (!Array.isArray(args.by) || args.by.length === 0) {
431
- throw new Error("groupBy requires a non-empty `by` array");
503
+ throw new MonliteQueryError("groupBy requires a non-empty `by` array");
432
504
  }
433
505
  const params = [];
434
506
  const where = buildWhere(args.where, {
@@ -437,13 +509,16 @@ function groupBy(ctx, args) {
437
509
  columns: ctx.columns
438
510
  });
439
511
  const groupExprs = [];
512
+ const groupCols = [];
440
513
  const selects = [];
441
- for (const field of args.by) {
514
+ args.by.forEach((field, gi) => {
442
515
  if (!isColumn(field, ctx.columns)) ctx.onPath(field);
443
516
  const expr = fieldExpr(field, ctx.columns);
517
+ const alias = `grp_${gi}`;
444
518
  groupExprs.push(expr);
445
- selects.push(`${expr} AS "${field}"`);
446
- }
519
+ selects.push(`${expr} AS ${alias}`);
520
+ groupCols.push({ alias, field });
521
+ });
447
522
  selects.push(`COUNT(*) AS agg_count`);
448
523
  const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
449
524
  selects.push(...accSelects);
@@ -472,7 +547,7 @@ function groupBy(ctx, args) {
472
547
  const rows = ctx.db.prepare(sql).all(...params);
473
548
  return rows.map((row) => {
474
549
  const out = {};
475
- for (const field of args.by) out[field] = row[field];
550
+ for (const { alias, field } of groupCols) out[field] = row[alias];
476
551
  if (args._count) out._count = row.agg_count;
477
552
  for (const col of cols) {
478
553
  (out[col.kind] ??= {})[col.field] = row[col.alias] ?? null;
@@ -511,6 +586,13 @@ var Collection = class {
511
586
  );
512
587
  }
513
588
  const normalized = typeof def === "string" ? { type: def } : def;
589
+ if (normalized.references && !/^[A-Za-z_][A-Za-z0-9_]*(\([A-Za-z_][A-Za-z0-9_]*\))?$/.test(
590
+ normalized.references
591
+ )) {
592
+ throw new MonliteError(
593
+ `Invalid references "${normalized.references}" on column "${field}"`
594
+ );
595
+ }
514
596
  this.columnDefs[field] = normalized;
515
597
  this.columnOrder.push(field);
516
598
  this.columns.add(field);
@@ -532,6 +614,14 @@ var Collection = class {
532
614
  get db() {
533
615
  return this.mon.driver;
534
616
  }
617
+ /** Run a DB operation, normalizing driver errors into typed MonliteErrors. */
618
+ guard(fn) {
619
+ try {
620
+ return fn();
621
+ } catch (err) {
622
+ throw normalizeDriverError(err, this.name);
623
+ }
624
+ }
535
625
  /** Native column names declared for this collection (structured mode). */
536
626
  get columnNames() {
537
627
  return [...this.columnOrder];
@@ -584,7 +674,11 @@ var Collection = class {
584
674
  if (this.mode === "structured") {
585
675
  for (const field of this.columnOrder) {
586
676
  const value = row[field];
587
- if (value === null || value === void 0) continue;
677
+ if (value === void 0) continue;
678
+ if (value === null) {
679
+ doc[field] = null;
680
+ continue;
681
+ }
588
682
  doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
589
683
  }
590
684
  }
@@ -597,6 +691,11 @@ var Collection = class {
597
691
  if (this.jsonColumns.has(field)) {
598
692
  return value === void 0 ? null : JSON.stringify(value);
599
693
  }
694
+ if (value !== null && typeof value === "object" && !(value instanceof Date) && !Buffer.isBuffer(value)) {
695
+ throw new MonliteQueryError(
696
+ `Column "${field}" cannot store an object/array. Declare it as { type: "JSON" } to store structured values.`
697
+ );
698
+ }
600
699
  return bindable(value);
601
700
  }
602
701
  insertColumns() {
@@ -679,21 +778,22 @@ var Collection = class {
679
778
  this.db.prepare(this.insertSql()).run(...row.values);
680
779
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
681
780
  };
682
- if (recorder) this.db.transaction(write);
683
- else write();
781
+ this.guard(() => recorder ? this.db.transaction(write) : write());
684
782
  return row.returned;
685
783
  }
686
784
  async createMany(args) {
687
785
  this.ensureTable();
688
786
  const stmt = this.db.prepare(this.insertSql());
689
787
  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
- });
788
+ this.guard(
789
+ () => this.db.transaction(() => {
790
+ for (const item of args.data) {
791
+ const row = this.buildInsert(item);
792
+ stmt.run(...row.values);
793
+ recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
794
+ }
795
+ })
796
+ );
697
797
  return { count: args.data.length };
698
798
  }
699
799
  /* ------------------------------ read ------------------------------ */
@@ -723,6 +823,28 @@ var Collection = class {
723
823
  const rows = await this.findMany({ ...args, take: 1 });
724
824
  return rows[0] ?? null;
725
825
  }
826
+ /** Alias of {@link findFirst} for Prisma familiarity. */
827
+ async findUnique(args = {}) {
828
+ return this.findFirst(args);
829
+ }
830
+ /** Like {@link findFirst} but throws if no document matches. */
831
+ async findFirstOrThrow(args = {}) {
832
+ const doc = await this.findFirst(args);
833
+ if (!doc) throw new MonliteError(`No document found in "${this.name}"`);
834
+ return doc;
835
+ }
836
+ /** True if at least one document matches. */
837
+ async exists(where) {
838
+ this.ensureTable();
839
+ const params = [];
840
+ const clause = buildWhere(where, {
841
+ params,
842
+ onPath: this.trackPath,
843
+ columns: this.columns
844
+ });
845
+ const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
846
+ return row != null;
847
+ }
726
848
  async findById(id) {
727
849
  this.ensureTable();
728
850
  const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
@@ -758,8 +880,10 @@ var Collection = class {
758
880
  this.trackPath(field);
759
881
  sql = `SELECT DISTINCT je.value AS v FROM "${this.name}" CROSS JOIN json_each("${this.name}".data, ${pathLiteral(field)}) je WHERE ${clause} ORDER BY v`;
760
882
  }
761
- const rows = this.db.prepare(sql).all(...params);
762
- return rows.map((r) => r.v);
883
+ return this.guard(() => {
884
+ const rows = this.db.prepare(sql).all(...params);
885
+ return rows.map((r) => r.v);
886
+ });
763
887
  }
764
888
  /* ----------------------------- update ----------------------------- */
765
889
  runUpdate(where, data, single) {
@@ -776,23 +900,25 @@ var Collection = class {
776
900
  if (!rows.length) return [];
777
901
  const now = Date.now();
778
902
  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
- });
903
+ return this.guard(
904
+ () => this.db.transaction(() => {
905
+ const out = [];
906
+ for (const row of rows) {
907
+ const current = stripSystem(this.rowToDoc(row));
908
+ const updated = stripSystem(applyUpdate(current, data));
909
+ const { setSql, values } = this.buildUpdateSet(updated, now);
910
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
911
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
912
+ out.push({
913
+ ...updated,
914
+ _id: row._id,
915
+ created_at: row.created_at,
916
+ updated_at: now
917
+ });
918
+ }
919
+ return out;
920
+ })
921
+ );
796
922
  }
797
923
  async update(args) {
798
924
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -802,15 +928,36 @@ var Collection = class {
802
928
  }
803
929
  async upsert(args) {
804
930
  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 });
931
+ return this.guard(
932
+ () => this.db.transaction(() => {
933
+ const params = [];
934
+ const clause = buildWhere(args.where, {
935
+ params,
936
+ onPath: this.trackPath,
937
+ columns: this.columns
938
+ });
939
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
940
+ const now = Date.now();
941
+ const recorder = this.recorder;
942
+ if (row) {
943
+ const current = stripSystem(this.rowToDoc(row));
944
+ const updated = stripSystem(applyUpdate(current, args.update));
945
+ const { setSql, values } = this.buildUpdateSet(updated, now);
946
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
947
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
948
+ return {
949
+ ...updated,
950
+ _id: row._id,
951
+ created_at: row.created_at,
952
+ updated_at: now
953
+ };
954
+ }
955
+ const ins = this.buildInsert(args.create);
956
+ this.db.prepare(this.insertSql()).run(...ins.values);
957
+ recorder?.recordLocal(this.name, ins._id, "upsert", ins.created_at);
958
+ return ins.returned;
959
+ })
960
+ );
814
961
  }
815
962
  /* ----------------------------- delete ----------------------------- */
816
963
  runDelete(where, single) {
@@ -828,12 +975,14 @@ var Collection = class {
828
975
  const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
829
976
  const recorder = this.recorder;
830
977
  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
- });
978
+ this.guard(
979
+ () => this.db.transaction(() => {
980
+ for (const row of rows) {
981
+ stmt.run(row._id);
982
+ recorder?.recordLocal(this.name, row._id, "delete", now);
983
+ }
984
+ })
985
+ );
837
986
  return rows.map((r) => this.rowToDoc(r));
838
987
  }
839
988
  async delete(args) {
@@ -845,16 +994,20 @@ var Collection = class {
845
994
  /* --------------------------- aggregation -------------------------- */
846
995
  async aggregate(args = {}) {
847
996
  this.ensureTable();
848
- return aggregate(
849
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
850
- args
997
+ return this.guard(
998
+ () => aggregate(
999
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1000
+ args
1001
+ )
851
1002
  );
852
1003
  }
853
1004
  async groupBy(args) {
854
1005
  this.ensureTable();
855
- return groupBy(
856
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
857
- args
1006
+ return this.guard(
1007
+ () => groupBy(
1008
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1009
+ args
1010
+ )
858
1011
  );
859
1012
  }
860
1013
  };
@@ -912,15 +1065,19 @@ var AutoIndexer = class {
912
1065
  };
913
1066
 
914
1067
  // src/driver/better-sqlite3.ts
1068
+ var STMT_CACHE_MAX = 256;
915
1069
  var BetterSqlite3Driver = class {
916
1070
  name = "better-sqlite3";
917
1071
  raw;
918
1072
  verbose;
1073
+ cache = /* @__PURE__ */ new Map();
919
1074
  constructor(BetterSqlite3, filename, options) {
920
1075
  this.verbose = options.verbose;
921
1076
  this.raw = new BetterSqlite3(filename, {
922
1077
  readonly: options.readonly ?? false
923
1078
  });
1079
+ this.raw.pragma("foreign_keys = ON");
1080
+ this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
924
1081
  if (!options.readonly && (options.wal ?? true)) {
925
1082
  this.raw.pragma("journal_mode = WAL");
926
1083
  }
@@ -931,21 +1088,35 @@ var BetterSqlite3Driver = class {
931
1088
  }
932
1089
  prepare(sql) {
933
1090
  this.verbose?.(sql);
934
- return this.raw.prepare(sql);
1091
+ const cached = this.cache.get(sql);
1092
+ if (cached) return cached;
1093
+ const stmt = this.raw.prepare(sql);
1094
+ this.cacheStmt(sql, stmt);
1095
+ return stmt;
1096
+ }
1097
+ cacheStmt(sql, stmt) {
1098
+ if (this.cache.size >= STMT_CACHE_MAX) {
1099
+ const oldest = this.cache.keys().next().value;
1100
+ if (oldest !== void 0) this.cache.delete(oldest);
1101
+ }
1102
+ this.cache.set(sql, stmt);
935
1103
  }
936
1104
  transaction(fn) {
937
1105
  return this.raw.transaction(fn)();
938
1106
  }
939
1107
  close() {
1108
+ this.cache.clear();
940
1109
  this.raw.close();
941
1110
  }
942
1111
  };
943
1112
 
944
1113
  // src/driver/node-sqlite.ts
1114
+ var STMT_CACHE_MAX2 = 256;
945
1115
  var NodeSqliteDriver = class {
946
1116
  name = "node:sqlite";
947
1117
  raw;
948
1118
  verbose;
1119
+ cache = /* @__PURE__ */ new Map();
949
1120
  depth = 0;
950
1121
  constructor(nodeSqlite, filename, options) {
951
1122
  this.verbose = options.verbose;
@@ -953,6 +1124,7 @@ var NodeSqliteDriver = class {
953
1124
  this.raw = new DatabaseSync(filename, {
954
1125
  readOnly: options.readonly ?? false
955
1126
  });
1127
+ this.raw.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
956
1128
  if (!options.readonly && (options.wal ?? true)) {
957
1129
  this.raw.exec("PRAGMA journal_mode = WAL");
958
1130
  }
@@ -963,12 +1135,20 @@ var NodeSqliteDriver = class {
963
1135
  }
964
1136
  prepare(sql) {
965
1137
  this.verbose?.(sql);
1138
+ const cached = this.cache.get(sql);
1139
+ if (cached) return cached;
966
1140
  const stmt = this.raw.prepare(sql);
967
- return {
1141
+ const wrapped = {
968
1142
  run: (...p) => stmt.run(...p),
969
1143
  get: (...p) => stmt.get(...p),
970
1144
  all: (...p) => stmt.all(...p)
971
1145
  };
1146
+ if (this.cache.size >= STMT_CACHE_MAX2) {
1147
+ const oldest = this.cache.keys().next().value;
1148
+ if (oldest !== void 0) this.cache.delete(oldest);
1149
+ }
1150
+ this.cache.set(sql, wrapped);
1151
+ return wrapped;
972
1152
  }
973
1153
  transaction(fn) {
974
1154
  const savepoint = `monlite_sp_${this.depth}`;
@@ -983,12 +1163,21 @@ var NodeSqliteDriver = class {
983
1163
  return result;
984
1164
  } catch (err) {
985
1165
  this.depth--;
986
- if (this.depth === 0) this.raw.exec("ROLLBACK");
987
- else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1166
+ try {
1167
+ if (this.depth === 0) this.raw.exec("ROLLBACK");
1168
+ else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1169
+ } catch {
1170
+ this.depth = 0;
1171
+ try {
1172
+ this.raw.exec("ROLLBACK");
1173
+ } catch {
1174
+ }
1175
+ }
988
1176
  throw err;
989
1177
  }
990
1178
  }
991
1179
  close() {
1180
+ this.cache.clear();
992
1181
  this.raw.close();
993
1182
  }
994
1183
  };
@@ -1041,8 +1230,10 @@ function createDriver(filename, options = {}) {
1041
1230
 
1042
1231
  // src/sync/version.ts
1043
1232
  var TS_WIDTH = 15;
1044
- function makeVersion(ts, nodeId) {
1045
- return String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1233
+ var SEQ_WIDTH = 12;
1234
+ function makeVersion(ts, nodeId, seq) {
1235
+ const base = String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1236
+ return seq === void 0 ? base : base + ":" + String(seq).padStart(SEQ_WIDTH, "0");
1046
1237
  }
1047
1238
  function compareVersions(a, b) {
1048
1239
  return a < b ? -1 : a > b ? 1 : 0;
@@ -1069,6 +1260,7 @@ var SyncStore = class {
1069
1260
  }
1070
1261
  db;
1071
1262
  nodeId;
1263
+ versionSeq = 0;
1072
1264
  init() {
1073
1265
  this.db.exec(`
1074
1266
  CREATE TABLE IF NOT EXISTS _monlite_changes (
@@ -1117,7 +1309,7 @@ var SyncStore = class {
1117
1309
  /* ----------------------- local change recording ----------------------- */
1118
1310
  /** Append a locally-originated change to the feed. Call inside a write txn. */
1119
1311
  recordLocal(collection, id, op, ts) {
1120
- const version = makeVersion(ts, this.nodeId);
1312
+ const version = makeVersion(ts, this.nodeId, this.versionSeq++);
1121
1313
  this.db.prepare(
1122
1314
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
1123
1315
  VALUES (?, ?, ?, ?, ?, 'local', 0)`
@@ -1134,13 +1326,18 @@ var SyncStore = class {
1134
1326
  }
1135
1327
  /* ----------------------------- push side ----------------------------- */
1136
1328
  /** Latest unpushed local change per document (the push queue). */
1137
- pending(collections) {
1329
+ pending(collections, limit) {
1138
1330
  const params = [];
1139
1331
  let collFilter = "";
1140
1332
  if (collections && collections.length) {
1141
1333
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1142
1334
  params.push(...collections);
1143
1335
  }
1336
+ let limitClause = "";
1337
+ if (limit != null && limit > 0) {
1338
+ limitClause = " LIMIT ?";
1339
+ params.push(limit);
1340
+ }
1144
1341
  const rows = this.db.prepare(
1145
1342
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1146
1343
  FROM _monlite_changes c
@@ -1150,7 +1347,7 @@ var SyncStore = class {
1150
1347
  WHERE source = 'local' AND pushed = 0${collFilter}
1151
1348
  GROUP BY coll, doc_id
1152
1349
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1153
- ORDER BY c.seq`
1350
+ ORDER BY c.seq${limitClause}`
1154
1351
  ).all(...params);
1155
1352
  return rows.map((r) => {
1156
1353
  const change = {
@@ -1188,31 +1385,31 @@ var SyncStore = class {
1188
1385
  */
1189
1386
  applyRemote(change, resolver) {
1190
1387
  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(() => {
1388
+ return this.db.transaction(() => {
1389
+ const localVersion = this.currentVersion(change.collection, change._id);
1390
+ let winner;
1391
+ if (localVersion === null) {
1392
+ winner = "remote";
1393
+ } else if (change.version === localVersion) {
1394
+ return { applied: false, conflict: false, winner: "none" };
1395
+ } else {
1396
+ winner = resolver ? resolver({
1397
+ collection: change.collection,
1398
+ _id: change._id,
1399
+ local: { version: localVersion },
1400
+ remote: { version: change.version, doc: change.doc }
1401
+ }) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
1402
+ this.recordConflict(
1403
+ change.collection,
1404
+ change._id,
1405
+ localVersion,
1406
+ change.version,
1407
+ winner
1408
+ );
1409
+ }
1410
+ if (winner !== "remote") {
1411
+ return { applied: false, conflict: localVersion !== null, winner };
1412
+ }
1216
1413
  this.applyData(change);
1217
1414
  this.db.prepare(
1218
1415
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
@@ -1224,8 +1421,8 @@ var SyncStore = class {
1224
1421
  change.version,
1225
1422
  versionTs(change.version)
1226
1423
  );
1424
+ return { applied: true, conflict: localVersion !== null, winner: "remote" };
1227
1425
  });
1228
- return { applied: true, conflict: localVersion !== null, winner: "remote" };
1229
1426
  }
1230
1427
  applyData(change) {
1231
1428
  const { collection: coll, _id, op } = change;
@@ -1248,13 +1445,18 @@ var SyncStore = class {
1248
1445
  * as RemoteChanges (used when this database acts as a sync *source*, e.g. the
1249
1446
  * monlite-as-remote adapter). Returns the new watermark to resume from.
1250
1447
  */
1251
- changesSince(seq, collections) {
1448
+ changesSince(seq, collections, limit) {
1252
1449
  const params = [seq];
1253
1450
  let collFilter = "";
1254
1451
  if (collections && collections.length) {
1255
1452
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1256
1453
  params.push(...collections);
1257
1454
  }
1455
+ let limitClause = "";
1456
+ if (limit != null && limit > 0) {
1457
+ limitClause = " LIMIT ?";
1458
+ params.push(limit);
1459
+ }
1258
1460
  const rows = this.db.prepare(
1259
1461
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1260
1462
  FROM _monlite_changes c
@@ -1264,7 +1466,7 @@ var SyncStore = class {
1264
1466
  WHERE seq > ?${collFilter}
1265
1467
  GROUP BY coll, doc_id
1266
1468
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1267
- ORDER BY c.seq`
1469
+ ORDER BY c.seq${limitClause}`
1268
1470
  ).all(...params);
1269
1471
  const changes = rows.map((r) => {
1270
1472
  const change = {
@@ -1280,8 +1482,8 @@ var SyncStore = class {
1280
1482
  }
1281
1483
  return change;
1282
1484
  });
1283
- const maxRow = this.db.prepare(`SELECT MAX(seq) AS m FROM _monlite_changes`).get();
1284
- return { changes, maxSeq: maxRow?.m ?? seq };
1485
+ const maxSeq = rows.length ? rows[rows.length - 1].seq : seq;
1486
+ return { changes, maxSeq };
1285
1487
  }
1286
1488
  /* ------------------------------ bootstrap ----------------------------- */
1287
1489
  /**
@@ -1414,6 +1616,7 @@ var Monlite = class {
1414
1616
  driver: options.driver,
1415
1617
  readonly: options.readonly,
1416
1618
  wal: options.wal,
1619
+ busyTimeout: options.busyTimeout,
1417
1620
  verbose: options.verbose
1418
1621
  });
1419
1622
  this.autoIndexer = new AutoIndexer(
@@ -1449,6 +1652,15 @@ var Monlite = class {
1449
1652
  if (!col) {
1450
1653
  col = new Collection(this, name, options);
1451
1654
  this.collections.set(name, col);
1655
+ } else if (options?.schema) {
1656
+ const requested = Object.keys(options.schema);
1657
+ const existing = new Set(col.columnNames);
1658
+ const sameShape = col.mode === "structured" && requested.length === existing.size && requested.every((c) => existing.has(c));
1659
+ if (!sameShape) {
1660
+ throw new MonliteError(
1661
+ `Collection "${name}" was already opened with a different schema/mode. A collection's storage mode is fixed on first access.`
1662
+ );
1663
+ }
1452
1664
  }
1453
1665
  return col;
1454
1666
  }
@@ -1483,7 +1695,11 @@ var Monlite = class {
1483
1695
  $executeRaw(strings, ...values) {
1484
1696
  this.assertOpen();
1485
1697
  const { sql, params } = buildTagged(strings, values);
1486
- return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1698
+ try {
1699
+ return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1700
+ } catch (err) {
1701
+ throw normalizeDriverError(err);
1702
+ }
1487
1703
  }
1488
1704
  /** Like {@link $executeRaw} but takes a raw SQL string and positional params. */
1489
1705
  $executeRawUnsafe(sql, ...params) {
@@ -1543,13 +1759,18 @@ function createDb(filename, options) {
1543
1759
 
1544
1760
  exports.Collection = Collection;
1545
1761
  exports.Monlite = Monlite;
1762
+ exports.MonliteConstraintError = MonliteConstraintError;
1546
1763
  exports.MonliteError = MonliteError;
1764
+ exports.MonliteForeignKeyError = MonliteForeignKeyError;
1765
+ exports.MonliteNotNullError = MonliteNotNullError;
1547
1766
  exports.MonliteQueryError = MonliteQueryError;
1767
+ exports.MonliteUniqueConstraintError = MonliteUniqueConstraintError;
1548
1768
  exports.SyncStore = SyncStore;
1549
1769
  exports.compareVersions = compareVersions;
1550
1770
  exports.createDb = createDb;
1551
1771
  exports.isObjectId = isObjectId;
1552
1772
  exports.makeVersion = makeVersion;
1773
+ exports.normalizeDriverError = normalizeDriverError;
1553
1774
  exports.objectId = objectId;
1554
1775
  exports.versionTs = versionTs;
1555
1776
  //# sourceMappingURL=index.cjs.map