@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.js CHANGED
@@ -19,17 +19,64 @@ 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"]);
@@ -56,9 +103,12 @@ function pathLiteral(field) {
56
103
  return "'" + jsonPath(field).replace(/'/g, "''") + "'";
57
104
  }
58
105
  function fieldExpr(field, columns) {
59
- if (isColumn(field, columns)) return `"${field}"`;
106
+ if (isColumn(field, columns)) return quoteIdent(field);
60
107
  return `json_extract(data, ${pathLiteral(field)})`;
61
108
  }
109
+ function quoteIdent(name) {
110
+ return `"${name.replace(/"/g, '""')}"`;
111
+ }
62
112
  function bindable(value) {
63
113
  if (value === void 0 || value === null) return null;
64
114
  if (typeof value === "boolean") return value ? 1 : 0;
@@ -109,10 +159,11 @@ function translateField(field, condition, ctx) {
109
159
  return eqExpr(expr, condition, ctx);
110
160
  }
111
161
  const filter = condition;
162
+ const ci = filter.mode === "insensitive";
112
163
  const clauses = [];
113
164
  for (const op of Object.keys(filter)) {
114
165
  const v = filter[op];
115
- if (v === void 0) continue;
166
+ if (v === void 0 || op === "mode") continue;
116
167
  switch (op) {
117
168
  case "equals":
118
169
  clauses.push(eqExpr(expr, v, ctx));
@@ -139,16 +190,20 @@ function translateField(field, condition, ctx) {
139
190
  clauses.push(inExpr(expr, v, ctx, true));
140
191
  break;
141
192
  case "contains":
142
- clauses.push(containsExpr(field, expr, v, ctx));
193
+ clauses.push(containsExpr(field, expr, v, ctx, ci));
143
194
  break;
144
195
  case "startsWith":
145
196
  ctx.params.push(bindable(v));
146
- clauses.push(`instr(${expr}, ?) = 1`);
197
+ clauses.push(
198
+ ci ? `instr(lower(${expr}), lower(?)) = 1` : `instr(${expr}, ?) = 1`
199
+ );
147
200
  break;
148
201
  case "endsWith":
149
202
  ctx.params.push(bindable(v));
150
203
  ctx.params.push(bindable(v));
151
- clauses.push(`substr(${expr}, -length(?)) = ?`);
204
+ clauses.push(
205
+ ci ? `substr(lower(${expr}), -length(?)) = lower(?)` : `substr(${expr}, -length(?)) = ?`
206
+ );
152
207
  break;
153
208
  case "has":
154
209
  clauses.push(hasExpr(field, expr, v, ctx));
@@ -192,15 +247,17 @@ function inExpr(expr, arr, ctx, negate) {
192
247
  }).join(", ");
193
248
  return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
194
249
  }
195
- function containsExpr(field, expr, v, ctx) {
250
+ function containsExpr(field, expr, v, ctx, ci) {
251
+ const sub = ci ? `instr(lower(${expr}), lower(?)) > 0` : `instr(${expr}, ?) > 0`;
196
252
  if (isColumn(field, ctx.columns)) {
197
253
  ctx.params.push(bindable(v));
198
- return `instr(${expr}, ?) > 0`;
254
+ return sub;
199
255
  }
200
256
  const path = pathLiteral(field);
257
+ const member = ci ? `lower(value) = lower(?)` : `value = ?`;
201
258
  ctx.params.push(bindable(v));
202
259
  ctx.params.push(bindable(v));
203
- return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE value = ?) ELSE instr(${expr}, ?) > 0 END)`;
260
+ return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE ${member}) ELSE ${sub} END)`;
204
261
  }
205
262
  function hasExpr(field, expr, v, ctx) {
206
263
  ctx.params.push(bindable(v));
@@ -233,16 +290,26 @@ function buildOrderBy(orderBy, onPath, columns) {
233
290
  }
234
291
 
235
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
+ }
236
303
  function getPath(obj, path) {
237
304
  let cur = obj;
238
- for (const seg of path.split(".")) {
305
+ for (const seg of safeSegments(path)) {
239
306
  if (cur == null) return void 0;
240
307
  cur = cur[seg];
241
308
  }
242
309
  return cur;
243
310
  }
244
311
  function setPath(obj, path, value) {
245
- const segs = path.split(".");
312
+ const segs = safeSegments(path);
246
313
  let cur = obj;
247
314
  for (let i = 0; i < segs.length - 1; i++) {
248
315
  const seg = segs[i];
@@ -252,7 +319,7 @@ function setPath(obj, path, value) {
252
319
  cur[segs[segs.length - 1]] = value;
253
320
  }
254
321
  function unsetPath(obj, path) {
255
- const segs = path.split(".");
322
+ const segs = safeSegments(path);
256
323
  let cur = obj;
257
324
  for (let i = 0; i < segs.length - 1; i++) {
258
325
  const seg = segs[i];
@@ -305,8 +372,13 @@ function applyUpdate(doc, data) {
305
372
  }
306
373
  if (ops.$inc) {
307
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
+ }
308
380
  const cur = getPath(next, path);
309
- setPath(next, path, (typeof cur === "number" ? cur : 0) + Number(by));
381
+ setPath(next, path, (typeof cur === "number" ? cur : 0) + by);
310
382
  }
311
383
  }
312
384
  if (ops.$push) {
@@ -425,7 +497,7 @@ function buildHaving(having, params, columns) {
425
497
  }
426
498
  function groupBy(ctx, args) {
427
499
  if (!Array.isArray(args.by) || args.by.length === 0) {
428
- throw new Error("groupBy requires a non-empty `by` array");
500
+ throw new MonliteQueryError("groupBy requires a non-empty `by` array");
429
501
  }
430
502
  const params = [];
431
503
  const where = buildWhere(args.where, {
@@ -434,13 +506,16 @@ function groupBy(ctx, args) {
434
506
  columns: ctx.columns
435
507
  });
436
508
  const groupExprs = [];
509
+ const groupCols = [];
437
510
  const selects = [];
438
- for (const field of args.by) {
511
+ args.by.forEach((field, gi) => {
439
512
  if (!isColumn(field, ctx.columns)) ctx.onPath(field);
440
513
  const expr = fieldExpr(field, ctx.columns);
514
+ const alias = `grp_${gi}`;
441
515
  groupExprs.push(expr);
442
- selects.push(`${expr} AS "${field}"`);
443
- }
516
+ selects.push(`${expr} AS ${alias}`);
517
+ groupCols.push({ alias, field });
518
+ });
444
519
  selects.push(`COUNT(*) AS agg_count`);
445
520
  const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
446
521
  selects.push(...accSelects);
@@ -469,7 +544,7 @@ function groupBy(ctx, args) {
469
544
  const rows = ctx.db.prepare(sql).all(...params);
470
545
  return rows.map((row) => {
471
546
  const out = {};
472
- for (const field of args.by) out[field] = row[field];
547
+ for (const { alias, field } of groupCols) out[field] = row[alias];
473
548
  if (args._count) out._count = row.agg_count;
474
549
  for (const col of cols) {
475
550
  (out[col.kind] ??= {})[col.field] = row[col.alias] ?? null;
@@ -508,6 +583,13 @@ var Collection = class {
508
583
  );
509
584
  }
510
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
+ }
511
593
  this.columnDefs[field] = normalized;
512
594
  this.columnOrder.push(field);
513
595
  this.columns.add(field);
@@ -529,6 +611,14 @@ var Collection = class {
529
611
  get db() {
530
612
  return this.mon.driver;
531
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
+ }
532
622
  /** Native column names declared for this collection (structured mode). */
533
623
  get columnNames() {
534
624
  return [...this.columnOrder];
@@ -581,7 +671,11 @@ var Collection = class {
581
671
  if (this.mode === "structured") {
582
672
  for (const field of this.columnOrder) {
583
673
  const value = row[field];
584
- if (value === null || value === void 0) continue;
674
+ if (value === void 0) continue;
675
+ if (value === null) {
676
+ doc[field] = null;
677
+ continue;
678
+ }
585
679
  doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
586
680
  }
587
681
  }
@@ -594,6 +688,11 @@ var Collection = class {
594
688
  if (this.jsonColumns.has(field)) {
595
689
  return value === void 0 ? null : JSON.stringify(value);
596
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
+ }
597
696
  return bindable(value);
598
697
  }
599
698
  insertColumns() {
@@ -676,21 +775,22 @@ var Collection = class {
676
775
  this.db.prepare(this.insertSql()).run(...row.values);
677
776
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
678
777
  };
679
- if (recorder) this.db.transaction(write);
680
- else write();
778
+ this.guard(() => recorder ? this.db.transaction(write) : write());
681
779
  return row.returned;
682
780
  }
683
781
  async createMany(args) {
684
782
  this.ensureTable();
685
783
  const stmt = this.db.prepare(this.insertSql());
686
784
  const recorder = this.recorder;
687
- this.db.transaction(() => {
688
- for (const item of args.data) {
689
- const row = this.buildInsert(item);
690
- stmt.run(...row.values);
691
- recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
692
- }
693
- });
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
+ })
793
+ );
694
794
  return { count: args.data.length };
695
795
  }
696
796
  /* ------------------------------ read ------------------------------ */
@@ -720,6 +820,28 @@ var Collection = class {
720
820
  const rows = await this.findMany({ ...args, take: 1 });
721
821
  return rows[0] ?? null;
722
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
+ }
723
845
  async findById(id) {
724
846
  this.ensureTable();
725
847
  const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
@@ -755,8 +877,10 @@ var Collection = class {
755
877
  this.trackPath(field);
756
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`;
757
879
  }
758
- const rows = this.db.prepare(sql).all(...params);
759
- 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
+ });
760
884
  }
761
885
  /* ----------------------------- update ----------------------------- */
762
886
  runUpdate(where, data, single) {
@@ -773,23 +897,25 @@ var Collection = class {
773
897
  if (!rows.length) return [];
774
898
  const now = Date.now();
775
899
  const recorder = this.recorder;
776
- return this.db.transaction(() => {
777
- const out = [];
778
- for (const row of rows) {
779
- const current = stripSystem(this.rowToDoc(row));
780
- const updated = stripSystem(applyUpdate(current, data));
781
- const { setSql, values } = this.buildUpdateSet(updated, now);
782
- this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
783
- recorder?.recordLocal(this.name, row._id, "upsert", now);
784
- out.push({
785
- ...updated,
786
- _id: row._id,
787
- created_at: row.created_at,
788
- updated_at: now
789
- });
790
- }
791
- return out;
792
- });
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
+ })
918
+ );
793
919
  }
794
920
  async update(args) {
795
921
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -799,15 +925,36 @@ var Collection = class {
799
925
  }
800
926
  async upsert(args) {
801
927
  this.ensureTable();
802
- const existing = await this.findFirst({ where: args.where });
803
- if (existing) {
804
- const updated = await this.update({
805
- where: { _id: existing._id },
806
- data: args.update
807
- });
808
- return updated;
809
- }
810
- 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
+ );
811
958
  }
812
959
  /* ----------------------------- delete ----------------------------- */
813
960
  runDelete(where, single) {
@@ -825,12 +972,14 @@ var Collection = class {
825
972
  const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
826
973
  const recorder = this.recorder;
827
974
  const now = Date.now();
828
- this.db.transaction(() => {
829
- for (const row of rows) {
830
- stmt.run(row._id);
831
- recorder?.recordLocal(this.name, row._id, "delete", now);
832
- }
833
- });
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
+ );
834
983
  return rows.map((r) => this.rowToDoc(r));
835
984
  }
836
985
  async delete(args) {
@@ -842,16 +991,20 @@ var Collection = class {
842
991
  /* --------------------------- aggregation -------------------------- */
843
992
  async aggregate(args = {}) {
844
993
  this.ensureTable();
845
- return aggregate(
846
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
847
- args
994
+ return this.guard(
995
+ () => aggregate(
996
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
997
+ args
998
+ )
848
999
  );
849
1000
  }
850
1001
  async groupBy(args) {
851
1002
  this.ensureTable();
852
- return groupBy(
853
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
854
- args
1003
+ return this.guard(
1004
+ () => groupBy(
1005
+ { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
1006
+ args
1007
+ )
855
1008
  );
856
1009
  }
857
1010
  };
@@ -909,15 +1062,19 @@ var AutoIndexer = class {
909
1062
  };
910
1063
 
911
1064
  // src/driver/better-sqlite3.ts
1065
+ var STMT_CACHE_MAX = 256;
912
1066
  var BetterSqlite3Driver = class {
913
1067
  name = "better-sqlite3";
914
1068
  raw;
915
1069
  verbose;
1070
+ cache = /* @__PURE__ */ new Map();
916
1071
  constructor(BetterSqlite3, filename, options) {
917
1072
  this.verbose = options.verbose;
918
1073
  this.raw = new BetterSqlite3(filename, {
919
1074
  readonly: options.readonly ?? false
920
1075
  });
1076
+ this.raw.pragma("foreign_keys = ON");
1077
+ this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
921
1078
  if (!options.readonly && (options.wal ?? true)) {
922
1079
  this.raw.pragma("journal_mode = WAL");
923
1080
  }
@@ -928,21 +1085,35 @@ var BetterSqlite3Driver = class {
928
1085
  }
929
1086
  prepare(sql) {
930
1087
  this.verbose?.(sql);
931
- 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);
932
1100
  }
933
1101
  transaction(fn) {
934
1102
  return this.raw.transaction(fn)();
935
1103
  }
936
1104
  close() {
1105
+ this.cache.clear();
937
1106
  this.raw.close();
938
1107
  }
939
1108
  };
940
1109
 
941
1110
  // src/driver/node-sqlite.ts
1111
+ var STMT_CACHE_MAX2 = 256;
942
1112
  var NodeSqliteDriver = class {
943
1113
  name = "node:sqlite";
944
1114
  raw;
945
1115
  verbose;
1116
+ cache = /* @__PURE__ */ new Map();
946
1117
  depth = 0;
947
1118
  constructor(nodeSqlite, filename, options) {
948
1119
  this.verbose = options.verbose;
@@ -950,6 +1121,7 @@ var NodeSqliteDriver = class {
950
1121
  this.raw = new DatabaseSync(filename, {
951
1122
  readOnly: options.readonly ?? false
952
1123
  });
1124
+ this.raw.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
953
1125
  if (!options.readonly && (options.wal ?? true)) {
954
1126
  this.raw.exec("PRAGMA journal_mode = WAL");
955
1127
  }
@@ -960,12 +1132,20 @@ var NodeSqliteDriver = class {
960
1132
  }
961
1133
  prepare(sql) {
962
1134
  this.verbose?.(sql);
1135
+ const cached = this.cache.get(sql);
1136
+ if (cached) return cached;
963
1137
  const stmt = this.raw.prepare(sql);
964
- return {
1138
+ const wrapped = {
965
1139
  run: (...p) => stmt.run(...p),
966
1140
  get: (...p) => stmt.get(...p),
967
1141
  all: (...p) => stmt.all(...p)
968
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;
969
1149
  }
970
1150
  transaction(fn) {
971
1151
  const savepoint = `monlite_sp_${this.depth}`;
@@ -980,12 +1160,21 @@ var NodeSqliteDriver = class {
980
1160
  return result;
981
1161
  } catch (err) {
982
1162
  this.depth--;
983
- if (this.depth === 0) this.raw.exec("ROLLBACK");
984
- 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
+ }
985
1173
  throw err;
986
1174
  }
987
1175
  }
988
1176
  close() {
1177
+ this.cache.clear();
989
1178
  this.raw.close();
990
1179
  }
991
1180
  };
@@ -1038,8 +1227,10 @@ function createDriver(filename, options = {}) {
1038
1227
 
1039
1228
  // src/sync/version.ts
1040
1229
  var TS_WIDTH = 15;
1041
- function makeVersion(ts, nodeId) {
1042
- return String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
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");
1043
1234
  }
1044
1235
  function compareVersions(a, b) {
1045
1236
  return a < b ? -1 : a > b ? 1 : 0;
@@ -1066,6 +1257,7 @@ var SyncStore = class {
1066
1257
  }
1067
1258
  db;
1068
1259
  nodeId;
1260
+ versionSeq = 0;
1069
1261
  init() {
1070
1262
  this.db.exec(`
1071
1263
  CREATE TABLE IF NOT EXISTS _monlite_changes (
@@ -1114,7 +1306,7 @@ var SyncStore = class {
1114
1306
  /* ----------------------- local change recording ----------------------- */
1115
1307
  /** Append a locally-originated change to the feed. Call inside a write txn. */
1116
1308
  recordLocal(collection, id, op, ts) {
1117
- const version = makeVersion(ts, this.nodeId);
1309
+ const version = makeVersion(ts, this.nodeId, this.versionSeq++);
1118
1310
  this.db.prepare(
1119
1311
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
1120
1312
  VALUES (?, ?, ?, ?, ?, 'local', 0)`
@@ -1131,13 +1323,18 @@ var SyncStore = class {
1131
1323
  }
1132
1324
  /* ----------------------------- push side ----------------------------- */
1133
1325
  /** Latest unpushed local change per document (the push queue). */
1134
- pending(collections) {
1326
+ pending(collections, limit) {
1135
1327
  const params = [];
1136
1328
  let collFilter = "";
1137
1329
  if (collections && collections.length) {
1138
1330
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1139
1331
  params.push(...collections);
1140
1332
  }
1333
+ let limitClause = "";
1334
+ if (limit != null && limit > 0) {
1335
+ limitClause = " LIMIT ?";
1336
+ params.push(limit);
1337
+ }
1141
1338
  const rows = this.db.prepare(
1142
1339
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1143
1340
  FROM _monlite_changes c
@@ -1147,7 +1344,7 @@ var SyncStore = class {
1147
1344
  WHERE source = 'local' AND pushed = 0${collFilter}
1148
1345
  GROUP BY coll, doc_id
1149
1346
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1150
- ORDER BY c.seq`
1347
+ ORDER BY c.seq${limitClause}`
1151
1348
  ).all(...params);
1152
1349
  return rows.map((r) => {
1153
1350
  const change = {
@@ -1185,31 +1382,31 @@ var SyncStore = class {
1185
1382
  */
1186
1383
  applyRemote(change, resolver) {
1187
1384
  assertName(change.collection);
1188
- const localVersion = this.currentVersion(change.collection, change._id);
1189
- let winner;
1190
- if (localVersion === null) {
1191
- winner = "remote";
1192
- } else if (change.version === localVersion) {
1193
- return { applied: false, conflict: false, winner: "none" };
1194
- } else {
1195
- winner = resolver ? resolver({
1196
- collection: change.collection,
1197
- _id: change._id,
1198
- local: { version: localVersion },
1199
- remote: { version: change.version, doc: change.doc }
1200
- }) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
1201
- this.recordConflict(
1202
- change.collection,
1203
- change._id,
1204
- localVersion,
1205
- change.version,
1206
- winner
1207
- );
1208
- }
1209
- if (winner !== "remote") {
1210
- return { applied: false, conflict: localVersion !== null, winner };
1211
- }
1212
- this.db.transaction(() => {
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
+ }
1213
1410
  this.applyData(change);
1214
1411
  this.db.prepare(
1215
1412
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
@@ -1221,8 +1418,8 @@ var SyncStore = class {
1221
1418
  change.version,
1222
1419
  versionTs(change.version)
1223
1420
  );
1421
+ return { applied: true, conflict: localVersion !== null, winner: "remote" };
1224
1422
  });
1225
- return { applied: true, conflict: localVersion !== null, winner: "remote" };
1226
1423
  }
1227
1424
  applyData(change) {
1228
1425
  const { collection: coll, _id, op } = change;
@@ -1245,13 +1442,18 @@ var SyncStore = class {
1245
1442
  * as RemoteChanges (used when this database acts as a sync *source*, e.g. the
1246
1443
  * monlite-as-remote adapter). Returns the new watermark to resume from.
1247
1444
  */
1248
- changesSince(seq, collections) {
1445
+ changesSince(seq, collections, limit) {
1249
1446
  const params = [seq];
1250
1447
  let collFilter = "";
1251
1448
  if (collections && collections.length) {
1252
1449
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1253
1450
  params.push(...collections);
1254
1451
  }
1452
+ let limitClause = "";
1453
+ if (limit != null && limit > 0) {
1454
+ limitClause = " LIMIT ?";
1455
+ params.push(limit);
1456
+ }
1255
1457
  const rows = this.db.prepare(
1256
1458
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1257
1459
  FROM _monlite_changes c
@@ -1261,7 +1463,7 @@ var SyncStore = class {
1261
1463
  WHERE seq > ?${collFilter}
1262
1464
  GROUP BY coll, doc_id
1263
1465
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1264
- ORDER BY c.seq`
1466
+ ORDER BY c.seq${limitClause}`
1265
1467
  ).all(...params);
1266
1468
  const changes = rows.map((r) => {
1267
1469
  const change = {
@@ -1277,8 +1479,8 @@ var SyncStore = class {
1277
1479
  }
1278
1480
  return change;
1279
1481
  });
1280
- const maxRow = this.db.prepare(`SELECT MAX(seq) AS m FROM _monlite_changes`).get();
1281
- return { changes, maxSeq: maxRow?.m ?? seq };
1482
+ const maxSeq = rows.length ? rows[rows.length - 1].seq : seq;
1483
+ return { changes, maxSeq };
1282
1484
  }
1283
1485
  /* ------------------------------ bootstrap ----------------------------- */
1284
1486
  /**
@@ -1411,6 +1613,7 @@ var Monlite = class {
1411
1613
  driver: options.driver,
1412
1614
  readonly: options.readonly,
1413
1615
  wal: options.wal,
1616
+ busyTimeout: options.busyTimeout,
1414
1617
  verbose: options.verbose
1415
1618
  });
1416
1619
  this.autoIndexer = new AutoIndexer(
@@ -1446,6 +1649,15 @@ var Monlite = class {
1446
1649
  if (!col) {
1447
1650
  col = new Collection(this, name, options);
1448
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
+ }
1449
1661
  }
1450
1662
  return col;
1451
1663
  }
@@ -1480,7 +1692,11 @@ var Monlite = class {
1480
1692
  $executeRaw(strings, ...values) {
1481
1693
  this.assertOpen();
1482
1694
  const { sql, params } = buildTagged(strings, values);
1483
- 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
+ }
1484
1700
  }
1485
1701
  /** Like {@link $executeRaw} but takes a raw SQL string and positional params. */
1486
1702
  $executeRawUnsafe(sql, ...params) {
@@ -1538,6 +1754,6 @@ function createDb(filename, options) {
1538
1754
  return new Monlite(filename, options);
1539
1755
  }
1540
1756
 
1541
- export { Collection, Monlite, MonliteError, MonliteQueryError, SyncStore, compareVersions, createDb, isObjectId, makeVersion, objectId, versionTs };
1757
+ export { Collection, Monlite, MonliteConstraintError, MonliteError, MonliteForeignKeyError, MonliteNotNullError, MonliteQueryError, MonliteUniqueConstraintError, SyncStore, compareVersions, createDb, isObjectId, makeVersion, normalizeDriverError, objectId, versionTs };
1542
1758
  //# sourceMappingURL=index.js.map
1543
1759
  //# sourceMappingURL=index.js.map