@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.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));
@@ -181,9 +236,7 @@ function cmp(expr, op, v, ctx) {
181
236
  }
182
237
  function inExpr(expr, arr, ctx, negate) {
183
238
  if (!Array.isArray(arr)) {
184
- throw new MonliteQueryError(
185
- `${negate ? "notIn" : "in"} expects an array`
186
- );
239
+ throw new MonliteQueryError(`${negate ? "notIn" : "in"} expects an array`);
187
240
  }
188
241
  if (arr.length === 0) return negate ? "1" : "0";
189
242
  const placeholders = arr.map((v) => {
@@ -192,15 +245,17 @@ function inExpr(expr, arr, ctx, negate) {
192
245
  }).join(", ");
193
246
  return negate ? `(${expr} IS NULL OR ${expr} NOT IN (${placeholders}))` : `${expr} IN (${placeholders})`;
194
247
  }
195
- function containsExpr(field, expr, v, ctx) {
248
+ function containsExpr(field, expr, v, ctx, ci) {
249
+ const sub = ci ? `instr(lower(${expr}), lower(?)) > 0` : `instr(${expr}, ?) > 0`;
196
250
  if (isColumn(field, ctx.columns)) {
197
251
  ctx.params.push(bindable(v));
198
- return `instr(${expr}, ?) > 0`;
252
+ return sub;
199
253
  }
200
254
  const path = pathLiteral(field);
255
+ const member = ci ? `lower(value) = lower(?)` : `value = ?`;
201
256
  ctx.params.push(bindable(v));
202
257
  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)`;
258
+ return `(CASE WHEN json_type(data, ${path}) = 'array' THEN EXISTS (SELECT 1 FROM json_each(data, ${path}) WHERE ${member}) ELSE ${sub} END)`;
204
259
  }
205
260
  function hasExpr(field, expr, v, ctx) {
206
261
  ctx.params.push(bindable(v));
@@ -233,16 +288,26 @@ function buildOrderBy(orderBy, onPath, columns) {
233
288
  }
234
289
 
235
290
  // src/query/path.ts
291
+ var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
292
+ function safeSegments(path) {
293
+ const segs = path.split(".");
294
+ for (const seg of segs) {
295
+ if (FORBIDDEN.has(seg)) {
296
+ throw new MonliteQueryError(`Illegal path segment "${seg}" in "${path}"`);
297
+ }
298
+ }
299
+ return segs;
300
+ }
236
301
  function getPath(obj, path) {
237
302
  let cur = obj;
238
- for (const seg of path.split(".")) {
303
+ for (const seg of safeSegments(path)) {
239
304
  if (cur == null) return void 0;
240
305
  cur = cur[seg];
241
306
  }
242
307
  return cur;
243
308
  }
244
309
  function setPath(obj, path, value) {
245
- const segs = path.split(".");
310
+ const segs = safeSegments(path);
246
311
  let cur = obj;
247
312
  for (let i = 0; i < segs.length - 1; i++) {
248
313
  const seg = segs[i];
@@ -252,7 +317,7 @@ function setPath(obj, path, value) {
252
317
  cur[segs[segs.length - 1]] = value;
253
318
  }
254
319
  function unsetPath(obj, path) {
255
- const segs = path.split(".");
320
+ const segs = safeSegments(path);
256
321
  let cur = obj;
257
322
  for (let i = 0; i < segs.length - 1; i++) {
258
323
  const seg = segs[i];
@@ -301,12 +366,18 @@ function applyUpdate(doc, data) {
301
366
  }
302
367
  const ops = data;
303
368
  if (ops.$set) {
304
- for (const [path, value] of Object.entries(ops.$set)) setPath(next, path, value);
369
+ for (const [path, value] of Object.entries(ops.$set))
370
+ setPath(next, path, value);
305
371
  }
306
372
  if (ops.$inc) {
307
373
  for (const [path, by] of Object.entries(ops.$inc)) {
374
+ if (typeof by !== "number" || !Number.isFinite(by)) {
375
+ throw new MonliteQueryError(
376
+ `$inc on "${path}" requires a finite number, got ${JSON.stringify(by)}`
377
+ );
378
+ }
308
379
  const cur = getPath(next, path);
309
- setPath(next, path, (typeof cur === "number" ? cur : 0) + Number(by));
380
+ setPath(next, path, (typeof cur === "number" ? cur : 0) + by);
310
381
  }
311
382
  }
312
383
  if (ops.$push) {
@@ -417,7 +488,11 @@ function buildHaving(having, params, columns) {
417
488
  if (!selection) continue;
418
489
  for (const field of Object.keys(selection)) {
419
490
  parts.push(
420
- ...comparisonSql(`${fn}(${fieldExpr(field, columns)})`, selection[field], params)
491
+ ...comparisonSql(
492
+ `${fn}(${fieldExpr(field, columns)})`,
493
+ selection[field],
494
+ params
495
+ )
421
496
  );
422
497
  }
423
498
  }
@@ -425,7 +500,7 @@ function buildHaving(having, params, columns) {
425
500
  }
426
501
  function groupBy(ctx, args) {
427
502
  if (!Array.isArray(args.by) || args.by.length === 0) {
428
- throw new Error("groupBy requires a non-empty `by` array");
503
+ throw new MonliteQueryError("groupBy requires a non-empty `by` array");
429
504
  }
430
505
  const params = [];
431
506
  const where = buildWhere(args.where, {
@@ -434,15 +509,22 @@ function groupBy(ctx, args) {
434
509
  columns: ctx.columns
435
510
  });
436
511
  const groupExprs = [];
512
+ const groupCols = [];
437
513
  const selects = [];
438
- for (const field of args.by) {
514
+ args.by.forEach((field, gi) => {
439
515
  if (!isColumn(field, ctx.columns)) ctx.onPath(field);
440
516
  const expr = fieldExpr(field, ctx.columns);
517
+ const alias = `grp_${gi}`;
441
518
  groupExprs.push(expr);
442
- selects.push(`${expr} AS "${field}"`);
443
- }
519
+ selects.push(`${expr} AS ${alias}`);
520
+ groupCols.push({ alias, field });
521
+ });
444
522
  selects.push(`COUNT(*) AS agg_count`);
445
- const { selects: accSelects, cols } = buildAccumulators(args, ctx.onPath, ctx.columns);
523
+ const { selects: accSelects, cols } = buildAccumulators(
524
+ args,
525
+ ctx.onPath,
526
+ ctx.columns
527
+ );
446
528
  selects.push(...accSelects);
447
529
  let sql = `SELECT ${selects.join(", ")} FROM "${ctx.table}" WHERE ${where} GROUP BY ${groupExprs.join(", ")}`;
448
530
  if (args.having) {
@@ -469,7 +551,7 @@ function groupBy(ctx, args) {
469
551
  const rows = ctx.db.prepare(sql).all(...params);
470
552
  return rows.map((row) => {
471
553
  const out = {};
472
- for (const field of args.by) out[field] = row[field];
554
+ for (const { alias, field } of groupCols) out[field] = row[alias];
473
555
  if (args._count) out._count = row.agg_count;
474
556
  for (const col of cols) {
475
557
  (out[col.kind] ??= {})[col.field] = row[col.alias] ?? null;
@@ -508,6 +590,13 @@ var Collection = class {
508
590
  );
509
591
  }
510
592
  const normalized = typeof def === "string" ? { type: def } : def;
593
+ if (normalized.references && !/^[A-Za-z_][A-Za-z0-9_]*(\([A-Za-z_][A-Za-z0-9_]*\))?$/.test(
594
+ normalized.references
595
+ )) {
596
+ throw new MonliteError(
597
+ `Invalid references "${normalized.references}" on column "${field}"`
598
+ );
599
+ }
511
600
  this.columnDefs[field] = normalized;
512
601
  this.columnOrder.push(field);
513
602
  this.columns.add(field);
@@ -529,6 +618,14 @@ var Collection = class {
529
618
  get db() {
530
619
  return this.mon.driver;
531
620
  }
621
+ /** Run a DB operation, normalizing driver errors into typed MonliteErrors. */
622
+ guard(fn) {
623
+ try {
624
+ return fn();
625
+ } catch (err) {
626
+ throw normalizeDriverError(err, this.name);
627
+ }
628
+ }
532
629
  /** Native column names declared for this collection (structured mode). */
533
630
  get columnNames() {
534
631
  return [...this.columnOrder];
@@ -556,7 +653,8 @@ var Collection = class {
556
653
  let line = `"${field}" ${sqliteType(def.type)}`;
557
654
  if (def.notNull) line += " NOT NULL";
558
655
  if (def.unique) line += " UNIQUE";
559
- if (def.default !== void 0) line += ` DEFAULT ${formatDefault(def.default)}`;
656
+ if (def.default !== void 0)
657
+ line += ` DEFAULT ${formatDefault(def.default)}`;
560
658
  if (def.references) line += ` REFERENCES ${def.references}`;
561
659
  lines.push(line);
562
660
  }
@@ -581,7 +679,11 @@ var Collection = class {
581
679
  if (this.mode === "structured") {
582
680
  for (const field of this.columnOrder) {
583
681
  const value = row[field];
584
- if (value === null || value === void 0) continue;
682
+ if (value === void 0) continue;
683
+ if (value === null) {
684
+ doc[field] = null;
685
+ continue;
686
+ }
585
687
  doc[field] = this.jsonColumns.has(field) ? JSON.parse(value) : value;
586
688
  }
587
689
  }
@@ -594,6 +696,11 @@ var Collection = class {
594
696
  if (this.jsonColumns.has(field)) {
595
697
  return value === void 0 ? null : JSON.stringify(value);
596
698
  }
699
+ if (value !== null && typeof value === "object" && !(value instanceof Date) && !Buffer.isBuffer(value)) {
700
+ throw new MonliteQueryError(
701
+ `Column "${field}" cannot store an object/array. Declare it as { type: "JSON" } to store structured values.`
702
+ );
703
+ }
597
704
  return bindable(value);
598
705
  }
599
706
  insertColumns() {
@@ -611,7 +718,12 @@ var Collection = class {
611
718
  const now = Date.now();
612
719
  const id = input._id != null ? String(input._id) : objectId();
613
720
  const doc = stripSystem(input);
614
- const returned = { ...doc, _id: id, created_at: now, updated_at: now };
721
+ const returned = {
722
+ ...doc,
723
+ _id: id,
724
+ created_at: now,
725
+ updated_at: now
726
+ };
615
727
  if (this.mode === "document") {
616
728
  return {
617
729
  _id: id,
@@ -663,9 +775,65 @@ var Collection = class {
663
775
  ];
664
776
  return { setSql: setParts.join(", "), values };
665
777
  }
666
- /** Sync store, but only for document collections (structured sync is future work). */
778
+ /** Sync store for recording local changes (both document and structured). */
667
779
  get recorder() {
668
- return this.mode === "document" ? this.mon.$sync : void 0;
780
+ return this.mon.$sync;
781
+ }
782
+ /** @internal Read a full document by id (mode-aware), synchronously. */
783
+ getRaw(id) {
784
+ this.ensureTable();
785
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
786
+ return row ? this.rowToDoc(row) : null;
787
+ }
788
+ /**
789
+ * @internal Apply a remote change to storage WITHOUT recording it to the
790
+ * change feed (the sync store records the `remote` feed row itself). Used by
791
+ * `@monlite/sync` so structured collections sync correctly through the same
792
+ * column/overflow split as local writes.
793
+ */
794
+ applyRemoteWrite(op, id, doc, ts) {
795
+ this.ensureTable();
796
+ if (op === "delete") {
797
+ this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`).run(id);
798
+ return;
799
+ }
800
+ const clean = stripSystem(doc ?? {});
801
+ const createdAt = typeof doc?.created_at === "number" ? doc.created_at : ts;
802
+ if (this.mode === "document") {
803
+ this.db.prepare(
804
+ `INSERT INTO "${this.name}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
805
+ ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
806
+ ).run(id, JSON.stringify(clean), createdAt, ts);
807
+ return;
808
+ }
809
+ const overflow = {};
810
+ const colValues = {};
811
+ for (const [k, v] of Object.entries(clean)) {
812
+ if (this.columns.has(k)) colValues[k] = v;
813
+ else overflow[k] = v;
814
+ }
815
+ const cols = [
816
+ "_id",
817
+ "created_at",
818
+ "updated_at",
819
+ "data",
820
+ ...this.columnOrder
821
+ ];
822
+ const values = [
823
+ id,
824
+ createdAt,
825
+ ts,
826
+ JSON.stringify(overflow),
827
+ ...this.columnOrder.map(
828
+ (c) => c in colValues ? this.encodeColumn(c, colValues[c]) : null
829
+ )
830
+ ];
831
+ const colList = cols.map((c) => `"${c}"`).join(", ");
832
+ const placeholders = cols.map(() => "?").join(", ");
833
+ const updateSet = cols.filter((c) => c !== "_id" && c !== "created_at").map((c) => `"${c}" = excluded."${c}"`).join(", ");
834
+ this.db.prepare(
835
+ `INSERT INTO "${this.name}" (${colList}) VALUES (${placeholders}) ON CONFLICT(_id) DO UPDATE SET ${updateSet}`
836
+ ).run(...values);
669
837
  }
670
838
  /* ----------------------------- create ----------------------------- */
671
839
  async create(args) {
@@ -676,21 +844,22 @@ var Collection = class {
676
844
  this.db.prepare(this.insertSql()).run(...row.values);
677
845
  recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
678
846
  };
679
- if (recorder) this.db.transaction(write);
680
- else write();
847
+ this.guard(() => recorder ? this.db.transaction(write) : write());
681
848
  return row.returned;
682
849
  }
683
850
  async createMany(args) {
684
851
  this.ensureTable();
685
852
  const stmt = this.db.prepare(this.insertSql());
686
853
  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
- });
854
+ this.guard(
855
+ () => this.db.transaction(() => {
856
+ for (const item of args.data) {
857
+ const row = this.buildInsert(item);
858
+ stmt.run(...row.values);
859
+ recorder?.recordLocal(this.name, row._id, "upsert", row.created_at);
860
+ }
861
+ })
862
+ );
694
863
  return { count: args.data.length };
695
864
  }
696
865
  /* ------------------------------ read ------------------------------ */
@@ -720,6 +889,28 @@ var Collection = class {
720
889
  const rows = await this.findMany({ ...args, take: 1 });
721
890
  return rows[0] ?? null;
722
891
  }
892
+ /** Alias of {@link findFirst} for Prisma familiarity. */
893
+ async findUnique(args = {}) {
894
+ return this.findFirst(args);
895
+ }
896
+ /** Like {@link findFirst} but throws if no document matches. */
897
+ async findFirstOrThrow(args = {}) {
898
+ const doc = await this.findFirst(args);
899
+ if (!doc) throw new MonliteError(`No document found in "${this.name}"`);
900
+ return doc;
901
+ }
902
+ /** True if at least one document matches. */
903
+ async exists(where) {
904
+ this.ensureTable();
905
+ const params = [];
906
+ const clause = buildWhere(where, {
907
+ params,
908
+ onPath: this.trackPath,
909
+ columns: this.columns
910
+ });
911
+ const row = this.db.prepare(`SELECT 1 FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
912
+ return row != null;
913
+ }
723
914
  async findById(id) {
724
915
  this.ensureTable();
725
916
  const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE _id = ?`).get(id);
@@ -755,8 +946,10 @@ var Collection = class {
755
946
  this.trackPath(field);
756
947
  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
948
  }
758
- const rows = this.db.prepare(sql).all(...params);
759
- return rows.map((r) => r.v);
949
+ return this.guard(() => {
950
+ const rows = this.db.prepare(sql).all(...params);
951
+ return rows.map((r) => r.v);
952
+ });
760
953
  }
761
954
  /* ----------------------------- update ----------------------------- */
762
955
  runUpdate(where, data, single) {
@@ -773,23 +966,25 @@ var Collection = class {
773
966
  if (!rows.length) return [];
774
967
  const now = Date.now();
775
968
  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
- });
969
+ return this.guard(
970
+ () => this.db.transaction(() => {
971
+ const out = [];
972
+ for (const row of rows) {
973
+ const current = stripSystem(this.rowToDoc(row));
974
+ const updated = stripSystem(applyUpdate(current, data));
975
+ const { setSql, values } = this.buildUpdateSet(updated, now);
976
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
977
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
978
+ out.push({
979
+ ...updated,
980
+ _id: row._id,
981
+ created_at: row.created_at,
982
+ updated_at: now
983
+ });
984
+ }
985
+ return out;
986
+ })
987
+ );
793
988
  }
794
989
  async update(args) {
795
990
  return this.runUpdate(args.where, args.data, true)[0] ?? null;
@@ -799,15 +994,36 @@ var Collection = class {
799
994
  }
800
995
  async upsert(args) {
801
996
  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 });
997
+ return this.guard(
998
+ () => this.db.transaction(() => {
999
+ const params = [];
1000
+ const clause = buildWhere(args.where, {
1001
+ params,
1002
+ onPath: this.trackPath,
1003
+ columns: this.columns
1004
+ });
1005
+ const row = this.db.prepare(`SELECT * FROM "${this.name}" WHERE ${clause} LIMIT 1`).get(...params);
1006
+ const now = Date.now();
1007
+ const recorder = this.recorder;
1008
+ if (row) {
1009
+ const current = stripSystem(this.rowToDoc(row));
1010
+ const updated = stripSystem(applyUpdate(current, args.update));
1011
+ const { setSql, values } = this.buildUpdateSet(updated, now);
1012
+ this.db.prepare(`UPDATE "${this.name}" SET ${setSql} WHERE _id = ?`).run(...values, row._id);
1013
+ recorder?.recordLocal(this.name, row._id, "upsert", now);
1014
+ return {
1015
+ ...updated,
1016
+ _id: row._id,
1017
+ created_at: row.created_at,
1018
+ updated_at: now
1019
+ };
1020
+ }
1021
+ const ins = this.buildInsert(args.create);
1022
+ this.db.prepare(this.insertSql()).run(...ins.values);
1023
+ recorder?.recordLocal(this.name, ins._id, "upsert", ins.created_at);
1024
+ return ins.returned;
1025
+ })
1026
+ );
811
1027
  }
812
1028
  /* ----------------------------- delete ----------------------------- */
813
1029
  runDelete(where, single) {
@@ -825,12 +1041,14 @@ var Collection = class {
825
1041
  const stmt = this.db.prepare(`DELETE FROM "${this.name}" WHERE _id = ?`);
826
1042
  const recorder = this.recorder;
827
1043
  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
- });
1044
+ this.guard(
1045
+ () => this.db.transaction(() => {
1046
+ for (const row of rows) {
1047
+ stmt.run(row._id);
1048
+ recorder?.recordLocal(this.name, row._id, "delete", now);
1049
+ }
1050
+ })
1051
+ );
834
1052
  return rows.map((r) => this.rowToDoc(r));
835
1053
  }
836
1054
  async delete(args) {
@@ -842,16 +1060,30 @@ var Collection = class {
842
1060
  /* --------------------------- aggregation -------------------------- */
843
1061
  async aggregate(args = {}) {
844
1062
  this.ensureTable();
845
- return aggregate(
846
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
847
- args
1063
+ return this.guard(
1064
+ () => aggregate(
1065
+ {
1066
+ db: this.db,
1067
+ table: this.name,
1068
+ onPath: this.trackPath,
1069
+ columns: this.columns
1070
+ },
1071
+ args
1072
+ )
848
1073
  );
849
1074
  }
850
1075
  async groupBy(args) {
851
1076
  this.ensureTable();
852
- return groupBy(
853
- { db: this.db, table: this.name, onPath: this.trackPath, columns: this.columns },
854
- args
1077
+ return this.guard(
1078
+ () => groupBy(
1079
+ {
1080
+ db: this.db,
1081
+ table: this.name,
1082
+ onPath: this.trackPath,
1083
+ columns: this.columns
1084
+ },
1085
+ args
1086
+ )
855
1087
  );
856
1088
  }
857
1089
  };
@@ -909,15 +1141,19 @@ var AutoIndexer = class {
909
1141
  };
910
1142
 
911
1143
  // src/driver/better-sqlite3.ts
1144
+ var STMT_CACHE_MAX = 256;
912
1145
  var BetterSqlite3Driver = class {
913
1146
  name = "better-sqlite3";
914
1147
  raw;
915
1148
  verbose;
1149
+ cache = /* @__PURE__ */ new Map();
916
1150
  constructor(BetterSqlite3, filename, options) {
917
1151
  this.verbose = options.verbose;
918
1152
  this.raw = new BetterSqlite3(filename, {
919
1153
  readonly: options.readonly ?? false
920
1154
  });
1155
+ this.raw.pragma("foreign_keys = ON");
1156
+ this.raw.pragma(`busy_timeout = ${options.busyTimeout ?? 5e3}`);
921
1157
  if (!options.readonly && (options.wal ?? true)) {
922
1158
  this.raw.pragma("journal_mode = WAL");
923
1159
  }
@@ -928,21 +1164,35 @@ var BetterSqlite3Driver = class {
928
1164
  }
929
1165
  prepare(sql) {
930
1166
  this.verbose?.(sql);
931
- return this.raw.prepare(sql);
1167
+ const cached = this.cache.get(sql);
1168
+ if (cached) return cached;
1169
+ const stmt = this.raw.prepare(sql);
1170
+ this.cacheStmt(sql, stmt);
1171
+ return stmt;
1172
+ }
1173
+ cacheStmt(sql, stmt) {
1174
+ if (this.cache.size >= STMT_CACHE_MAX) {
1175
+ const oldest = this.cache.keys().next().value;
1176
+ if (oldest !== void 0) this.cache.delete(oldest);
1177
+ }
1178
+ this.cache.set(sql, stmt);
932
1179
  }
933
1180
  transaction(fn) {
934
1181
  return this.raw.transaction(fn)();
935
1182
  }
936
1183
  close() {
1184
+ this.cache.clear();
937
1185
  this.raw.close();
938
1186
  }
939
1187
  };
940
1188
 
941
1189
  // src/driver/node-sqlite.ts
1190
+ var STMT_CACHE_MAX2 = 256;
942
1191
  var NodeSqliteDriver = class {
943
1192
  name = "node:sqlite";
944
1193
  raw;
945
1194
  verbose;
1195
+ cache = /* @__PURE__ */ new Map();
946
1196
  depth = 0;
947
1197
  constructor(nodeSqlite, filename, options) {
948
1198
  this.verbose = options.verbose;
@@ -950,6 +1200,7 @@ var NodeSqliteDriver = class {
950
1200
  this.raw = new DatabaseSync(filename, {
951
1201
  readOnly: options.readonly ?? false
952
1202
  });
1203
+ this.raw.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
953
1204
  if (!options.readonly && (options.wal ?? true)) {
954
1205
  this.raw.exec("PRAGMA journal_mode = WAL");
955
1206
  }
@@ -960,12 +1211,20 @@ var NodeSqliteDriver = class {
960
1211
  }
961
1212
  prepare(sql) {
962
1213
  this.verbose?.(sql);
1214
+ const cached = this.cache.get(sql);
1215
+ if (cached) return cached;
963
1216
  const stmt = this.raw.prepare(sql);
964
- return {
1217
+ const wrapped = {
965
1218
  run: (...p) => stmt.run(...p),
966
1219
  get: (...p) => stmt.get(...p),
967
1220
  all: (...p) => stmt.all(...p)
968
1221
  };
1222
+ if (this.cache.size >= STMT_CACHE_MAX2) {
1223
+ const oldest = this.cache.keys().next().value;
1224
+ if (oldest !== void 0) this.cache.delete(oldest);
1225
+ }
1226
+ this.cache.set(sql, wrapped);
1227
+ return wrapped;
969
1228
  }
970
1229
  transaction(fn) {
971
1230
  const savepoint = `monlite_sp_${this.depth}`;
@@ -980,12 +1239,21 @@ var NodeSqliteDriver = class {
980
1239
  return result;
981
1240
  } catch (err) {
982
1241
  this.depth--;
983
- if (this.depth === 0) this.raw.exec("ROLLBACK");
984
- else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1242
+ try {
1243
+ if (this.depth === 0) this.raw.exec("ROLLBACK");
1244
+ else this.raw.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`);
1245
+ } catch {
1246
+ this.depth = 0;
1247
+ try {
1248
+ this.raw.exec("ROLLBACK");
1249
+ } catch {
1250
+ }
1251
+ }
985
1252
  throw err;
986
1253
  }
987
1254
  }
988
1255
  close() {
1256
+ this.cache.clear();
989
1257
  this.raw.close();
990
1258
  }
991
1259
  };
@@ -1038,8 +1306,10 @@ function createDriver(filename, options = {}) {
1038
1306
 
1039
1307
  // src/sync/version.ts
1040
1308
  var TS_WIDTH = 15;
1041
- function makeVersion(ts, nodeId) {
1042
- return String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1309
+ var SEQ_WIDTH = 12;
1310
+ function makeVersion(ts, nodeId, seq) {
1311
+ const base = String(ts).padStart(TS_WIDTH, "0") + ":" + nodeId;
1312
+ return seq === void 0 ? base : base + ":" + String(seq).padStart(SEQ_WIDTH, "0");
1043
1313
  }
1044
1314
  function compareVersions(a, b) {
1045
1315
  return a < b ? -1 : a > b ? 1 : 0;
@@ -1059,13 +1329,16 @@ function stripSystem2(obj) {
1059
1329
  return rest;
1060
1330
  }
1061
1331
  var SyncStore = class {
1062
- constructor(db, nodeId) {
1332
+ constructor(db, nodeId, mon) {
1063
1333
  this.db = db;
1334
+ this.mon = mon;
1064
1335
  this.init();
1065
1336
  this.nodeId = this.resolveNodeId(nodeId);
1066
1337
  }
1067
1338
  db;
1339
+ mon;
1068
1340
  nodeId;
1341
+ versionSeq = 0;
1069
1342
  init() {
1070
1343
  this.db.exec(`
1071
1344
  CREATE TABLE IF NOT EXISTS _monlite_changes (
@@ -1098,7 +1371,9 @@ var SyncStore = class {
1098
1371
  }
1099
1372
  resolveNodeId(explicit) {
1100
1373
  if (explicit) {
1101
- this.db.prepare(`INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`).run(explicit);
1374
+ this.db.prepare(
1375
+ `INSERT OR REPLACE INTO _monlite_meta (key, value) VALUES ('nodeId', ?)`
1376
+ ).run(explicit);
1102
1377
  return explicit;
1103
1378
  }
1104
1379
  const row = this.db.prepare(`SELECT value FROM _monlite_meta WHERE key = 'nodeId'`).get();
@@ -1114,7 +1389,7 @@ var SyncStore = class {
1114
1389
  /* ----------------------- local change recording ----------------------- */
1115
1390
  /** Append a locally-originated change to the feed. Call inside a write txn. */
1116
1391
  recordLocal(collection, id, op, ts) {
1117
- const version = makeVersion(ts, this.nodeId);
1392
+ const version = makeVersion(ts, this.nodeId, this.versionSeq++);
1118
1393
  this.db.prepare(
1119
1394
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
1120
1395
  VALUES (?, ?, ?, ?, ?, 'local', 0)`
@@ -1131,13 +1406,18 @@ var SyncStore = class {
1131
1406
  }
1132
1407
  /* ----------------------------- push side ----------------------------- */
1133
1408
  /** Latest unpushed local change per document (the push queue). */
1134
- pending(collections) {
1409
+ pending(collections, limit) {
1135
1410
  const params = [];
1136
1411
  let collFilter = "";
1137
1412
  if (collections && collections.length) {
1138
1413
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1139
1414
  params.push(...collections);
1140
1415
  }
1416
+ let limitClause = "";
1417
+ if (limit != null && limit > 0) {
1418
+ limitClause = " LIMIT ?";
1419
+ params.push(limit);
1420
+ }
1141
1421
  const rows = this.db.prepare(
1142
1422
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1143
1423
  FROM _monlite_changes c
@@ -1147,7 +1427,7 @@ var SyncStore = class {
1147
1427
  WHERE source = 'local' AND pushed = 0${collFilter}
1148
1428
  GROUP BY coll, doc_id
1149
1429
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1150
- ORDER BY c.seq`
1430
+ ORDER BY c.seq${limitClause}`
1151
1431
  ).all(...params);
1152
1432
  return rows.map((r) => {
1153
1433
  const change = {
@@ -1185,31 +1465,34 @@ var SyncStore = class {
1185
1465
  */
1186
1466
  applyRemote(change, resolver) {
1187
1467
  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(() => {
1468
+ return this.db.transaction(() => {
1469
+ const localVersion = this.currentVersion(change.collection, change._id);
1470
+ let winner;
1471
+ if (localVersion === null) {
1472
+ winner = "remote";
1473
+ } else if (change.version === localVersion) {
1474
+ return { applied: false, conflict: false, winner: "none" };
1475
+ } else {
1476
+ winner = resolver ? resolver({
1477
+ collection: change.collection,
1478
+ _id: change._id,
1479
+ local: { version: localVersion },
1480
+ remote: { version: change.version, doc: change.doc }
1481
+ }) : compareVersions(change.version, localVersion) > 0 ? "remote" : "local";
1482
+ this.recordConflict(
1483
+ change.collection,
1484
+ change._id,
1485
+ localVersion,
1486
+ change.version,
1487
+ winner
1488
+ );
1489
+ }
1490
+ if (winner !== "remote") {
1491
+ if (localVersion !== null) {
1492
+ this.recordLocal(change.collection, change._id, "upsert", Date.now());
1493
+ }
1494
+ return { applied: false, conflict: localVersion !== null, winner };
1495
+ }
1213
1496
  this.applyData(change);
1214
1497
  this.db.prepare(
1215
1498
  `INSERT INTO _monlite_changes (coll, doc_id, op, version, ts, source, pushed)
@@ -1221,37 +1504,49 @@ var SyncStore = class {
1221
1504
  change.version,
1222
1505
  versionTs(change.version)
1223
1506
  );
1507
+ return {
1508
+ applied: true,
1509
+ conflict: localVersion !== null,
1510
+ winner: "remote"
1511
+ };
1224
1512
  });
1225
- return { applied: true, conflict: localVersion !== null, winner: "remote" };
1226
1513
  }
1227
1514
  applyData(change) {
1228
- const { collection: coll, _id, op } = change;
1515
+ const ts = versionTs(change.version);
1516
+ if (this.mon) {
1517
+ this.mon.collection(change.collection).applyRemoteWrite(change.op, change._id, change.doc, ts);
1518
+ return;
1519
+ }
1520
+ const coll = change.collection;
1229
1521
  this.ensureCollTable(coll);
1230
- if (op === "delete") {
1231
- this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(_id);
1522
+ if (change.op === "delete") {
1523
+ this.db.prepare(`DELETE FROM "${coll}" WHERE _id = ?`).run(change._id);
1232
1524
  return;
1233
1525
  }
1234
1526
  const doc = change.doc ?? {};
1235
- const data = JSON.stringify(stripSystem2(doc));
1236
- const ts = versionTs(change.version);
1237
1527
  const createdAt = typeof doc.created_at === "number" ? doc.created_at : ts;
1238
1528
  this.db.prepare(
1239
1529
  `INSERT INTO "${coll}" (_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)
1240
1530
  ON CONFLICT(_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
1241
- ).run(_id, data, createdAt, ts);
1531
+ ).run(change._id, JSON.stringify(stripSystem2(doc)), createdAt, ts);
1242
1532
  }
1243
1533
  /**
1244
1534
  * Latest change per document with `seq` greater than the given watermark,
1245
1535
  * as RemoteChanges (used when this database acts as a sync *source*, e.g. the
1246
1536
  * monlite-as-remote adapter). Returns the new watermark to resume from.
1247
1537
  */
1248
- changesSince(seq, collections) {
1538
+ changesSince(seq, collections, limit) {
1249
1539
  const params = [seq];
1250
1540
  let collFilter = "";
1251
1541
  if (collections && collections.length) {
1252
1542
  collFilter = ` AND coll IN (${collections.map(() => "?").join(", ")})`;
1253
1543
  params.push(...collections);
1254
1544
  }
1545
+ let limitClause = "";
1546
+ if (limit != null && limit > 0) {
1547
+ limitClause = " LIMIT ?";
1548
+ params.push(limit);
1549
+ }
1255
1550
  const rows = this.db.prepare(
1256
1551
  `SELECT c.seq, c.coll, c.doc_id, c.op, c.version, c.ts
1257
1552
  FROM _monlite_changes c
@@ -1261,7 +1556,7 @@ var SyncStore = class {
1261
1556
  WHERE seq > ?${collFilter}
1262
1557
  GROUP BY coll, doc_id
1263
1558
  ) m ON c.coll = m.coll AND c.doc_id = m.doc_id AND c.seq = m.mseq
1264
- ORDER BY c.seq`
1559
+ ORDER BY c.seq${limitClause}`
1265
1560
  ).all(...params);
1266
1561
  const changes = rows.map((r) => {
1267
1562
  const change = {
@@ -1277,8 +1572,8 @@ var SyncStore = class {
1277
1572
  }
1278
1573
  return change;
1279
1574
  });
1280
- const maxRow = this.db.prepare(`SELECT MAX(seq) AS m FROM _monlite_changes`).get();
1281
- return { changes, maxSeq: maxRow?.m ?? seq };
1575
+ const maxSeq = rows.length ? rows[rows.length - 1].seq : seq;
1576
+ return { changes, maxSeq };
1282
1577
  }
1283
1578
  /* ------------------------------ bootstrap ----------------------------- */
1284
1579
  /**
@@ -1290,6 +1585,7 @@ var SyncStore = class {
1290
1585
  this.db.transaction(() => {
1291
1586
  for (const coll of collections) {
1292
1587
  assertName(coll);
1588
+ if (!this.tableExists(coll)) continue;
1293
1589
  const docs = this.db.prepare(`SELECT _id, updated_at FROM "${coll}"`).all();
1294
1590
  for (const d of docs) {
1295
1591
  if (this.currentVersion(coll, d._id) !== null) continue;
@@ -1335,7 +1631,14 @@ var SyncStore = class {
1335
1631
  this.db.prepare(
1336
1632
  `INSERT INTO _monlite_conflicts (coll, doc_id, local_version, remote_version, winner, ts)
1337
1633
  VALUES (?, ?, ?, ?, ?, ?)`
1338
- ).run(coll, id, localVersion, remoteVersion, winner, versionTs(remoteVersion));
1634
+ ).run(
1635
+ coll,
1636
+ id,
1637
+ localVersion,
1638
+ remoteVersion,
1639
+ winner,
1640
+ versionTs(remoteVersion)
1641
+ );
1339
1642
  }
1340
1643
  conflicts() {
1341
1644
  const rows = this.db.prepare(
@@ -1354,6 +1657,8 @@ var SyncStore = class {
1354
1657
  /* ------------------------------ helpers ------------------------------- */
1355
1658
  readDoc(coll, id) {
1356
1659
  assertName(coll);
1660
+ if (this.mon) return this.mon.collection(coll).getRaw(id);
1661
+ if (!this.tableExists(coll)) return null;
1357
1662
  const row = this.db.prepare(
1358
1663
  `SELECT _id, data, created_at, updated_at FROM "${coll}" WHERE _id = ?`
1359
1664
  ).get(id);
@@ -1364,6 +1669,9 @@ var SyncStore = class {
1364
1669
  doc.updated_at = row.updated_at;
1365
1670
  return doc;
1366
1671
  }
1672
+ tableExists(name) {
1673
+ return this.db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?`).get(name) != null;
1674
+ }
1367
1675
  ensureCollTable(coll) {
1368
1676
  assertName(coll);
1369
1677
  this.db.exec(
@@ -1411,6 +1719,7 @@ var Monlite = class {
1411
1719
  driver: options.driver,
1412
1720
  readonly: options.readonly,
1413
1721
  wal: options.wal,
1722
+ busyTimeout: options.busyTimeout,
1414
1723
  verbose: options.verbose
1415
1724
  });
1416
1725
  this.autoIndexer = new AutoIndexer(
@@ -1419,7 +1728,7 @@ var Monlite = class {
1419
1728
  options.autoIndexAfter ?? 10
1420
1729
  );
1421
1730
  if (options.sync) {
1422
- this.$sync = new SyncStore(this.driver, options.nodeId);
1731
+ this.$sync = new SyncStore(this.driver, options.nodeId, this);
1423
1732
  }
1424
1733
  }
1425
1734
  /** Stable node id for LWW tie-breaking (only when sync is enabled). */
@@ -1446,6 +1755,15 @@ var Monlite = class {
1446
1755
  if (!col) {
1447
1756
  col = new Collection(this, name, options);
1448
1757
  this.collections.set(name, col);
1758
+ } else if (options?.schema) {
1759
+ const requested = Object.keys(options.schema);
1760
+ const existing = new Set(col.columnNames);
1761
+ const sameShape = col.mode === "structured" && requested.length === existing.size && requested.every((c) => existing.has(c));
1762
+ if (!sameShape) {
1763
+ throw new MonliteError(
1764
+ `Collection "${name}" was already opened with a different schema/mode. A collection's storage mode is fixed on first access.`
1765
+ );
1766
+ }
1449
1767
  }
1450
1768
  return col;
1451
1769
  }
@@ -1480,7 +1798,11 @@ var Monlite = class {
1480
1798
  $executeRaw(strings, ...values) {
1481
1799
  this.assertOpen();
1482
1800
  const { sql, params } = buildTagged(strings, values);
1483
- return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1801
+ try {
1802
+ return Promise.resolve(this.driver.prepare(sql).run(...params).changes);
1803
+ } catch (err) {
1804
+ throw normalizeDriverError(err);
1805
+ }
1484
1806
  }
1485
1807
  /** Like {@link $executeRaw} but takes a raw SQL string and positional params. */
1486
1808
  $executeRawUnsafe(sql, ...params) {
@@ -1538,6 +1860,6 @@ function createDb(filename, options) {
1538
1860
  return new Monlite(filename, options);
1539
1861
  }
1540
1862
 
1541
- export { Collection, Monlite, MonliteError, MonliteQueryError, SyncStore, compareVersions, createDb, isObjectId, makeVersion, objectId, versionTs };
1863
+ export { Collection, Monlite, MonliteConstraintError, MonliteError, MonliteForeignKeyError, MonliteNotNullError, MonliteQueryError, MonliteUniqueConstraintError, SyncStore, compareVersions, createDb, isObjectId, makeVersion, normalizeDriverError, objectId, versionTs };
1542
1864
  //# sourceMappingURL=index.js.map
1543
1865
  //# sourceMappingURL=index.js.map