@korajs/server 0.3.1 → 0.3.3

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
@@ -1,10 +1,154 @@
1
1
  // src/store/memory-server-store.ts
2
2
  import { generateUUIDv7 } from "@korajs/core";
3
+
4
+ // src/store/materialization.ts
5
+ function fieldTypeToSql(descriptor, dialect) {
6
+ switch (descriptor.kind) {
7
+ case "string":
8
+ return "TEXT";
9
+ case "number":
10
+ return dialect === "postgres" ? "DOUBLE PRECISION" : "REAL";
11
+ case "boolean":
12
+ return "INTEGER";
13
+ case "enum":
14
+ return "TEXT";
15
+ case "timestamp":
16
+ return dialect === "postgres" ? "BIGINT" : "INTEGER";
17
+ case "array":
18
+ return dialect === "postgres" ? "JSONB" : "TEXT";
19
+ case "richtext":
20
+ return dialect === "postgres" ? "BYTEA" : "BLOB";
21
+ }
22
+ }
23
+ function sqlDefaultLiteral(value) {
24
+ if (value === null) return "NULL";
25
+ if (typeof value === "string") return `'${value}'`;
26
+ if (typeof value === "number") return String(value);
27
+ if (typeof value === "boolean") return value ? "1" : "0";
28
+ return `'${JSON.stringify(value)}'`;
29
+ }
30
+ function generateCollectionDDL(name, collection, dialect) {
31
+ const statements = [];
32
+ const columns = ["id TEXT PRIMARY KEY NOT NULL"];
33
+ for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
34
+ const sqlType = fieldTypeToSql(descriptor, dialect);
35
+ let colDef = `${fieldName} ${sqlType}`;
36
+ if (descriptor.defaultValue !== void 0) {
37
+ colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
38
+ }
39
+ if (descriptor.kind === "enum" && descriptor.enumValues) {
40
+ const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
41
+ colDef += ` CHECK (${fieldName} IN (${values}))`;
42
+ }
43
+ columns.push(colDef);
44
+ }
45
+ const tsType = dialect === "postgres" ? "BIGINT" : "INTEGER";
46
+ columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
47
+ columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
48
+ columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
49
+ statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
50
+ ${columns.join(",\n ")}
51
+ )`);
52
+ for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
53
+ const sqlType = fieldTypeToSql(descriptor, dialect);
54
+ let colDef = `${fieldName} ${sqlType}`;
55
+ if (descriptor.defaultValue !== void 0) {
56
+ colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
57
+ }
58
+ statements.push(`--kora:safe-alter
59
+ ALTER TABLE ${name} ADD COLUMN ${colDef}`);
60
+ }
61
+ for (const indexField of collection.indexes) {
62
+ statements.push(
63
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
64
+ );
65
+ }
66
+ statements.push(
67
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`
68
+ );
69
+ return statements;
70
+ }
71
+ function generateAllCollectionDDL(schema, dialect) {
72
+ const statements = [];
73
+ for (const [name, collection] of Object.entries(schema.collections)) {
74
+ statements.push(...generateCollectionDDL(name, collection, dialect));
75
+ }
76
+ return statements;
77
+ }
78
+ function replayOperationsForRecord(ops) {
79
+ let record = null;
80
+ let deleted = false;
81
+ for (const op of ops) {
82
+ switch (op.type) {
83
+ case "insert":
84
+ if (op.data) {
85
+ record = { ...op.data };
86
+ deleted = false;
87
+ }
88
+ break;
89
+ case "update":
90
+ if (op.data) {
91
+ record = { ...record ?? {}, ...op.data };
92
+ deleted = false;
93
+ }
94
+ break;
95
+ case "delete":
96
+ deleted = true;
97
+ break;
98
+ }
99
+ }
100
+ return deleted ? null : record;
101
+ }
102
+ function serializeFieldValue(value, descriptor) {
103
+ if (value === null || value === void 0) return null;
104
+ switch (descriptor.kind) {
105
+ case "array":
106
+ return typeof value === "string" ? value : JSON.stringify(value);
107
+ case "boolean":
108
+ return value ? 1 : 0;
109
+ default:
110
+ return value;
111
+ }
112
+ }
113
+ function deserializeFieldValue(value, descriptor) {
114
+ if (value === null || value === void 0) return null;
115
+ switch (descriptor.kind) {
116
+ case "array":
117
+ return typeof value === "string" ? JSON.parse(value) : value;
118
+ case "boolean":
119
+ return value === 1 || value === true;
120
+ default:
121
+ return value;
122
+ }
123
+ }
124
+ function validateFieldName(collectionName, fieldName, schema) {
125
+ const collection = schema.collections[collectionName];
126
+ if (!collection) {
127
+ throw new Error(`Unknown collection: ${collectionName}`);
128
+ }
129
+ const validFields = /* @__PURE__ */ new Set([
130
+ "id",
131
+ "_created_at",
132
+ "_updated_at",
133
+ "_deleted",
134
+ ...Object.keys(collection.fields)
135
+ ]);
136
+ if (!validFields.has(fieldName)) {
137
+ throw new Error(
138
+ `Invalid field name "${fieldName}" for collection "${collectionName}". Valid fields: ${Array.from(validFields).join(", ")}`
139
+ );
140
+ }
141
+ }
142
+
143
+ // src/store/memory-server-store.ts
3
144
  var MemoryServerStore = class {
4
145
  nodeId;
5
146
  operations = [];
6
147
  operationIndex = /* @__PURE__ */ new Map();
7
148
  versionVector = /* @__PURE__ */ new Map();
149
+ schema = null;
150
+ /** Materialized records: collection -> recordId -> record data */
151
+ materializedRecords = /* @__PURE__ */ new Map();
8
152
  closed = false;
9
153
  constructor(nodeId) {
10
154
  this.nodeId = nodeId ?? generateUUIDv7();
@@ -15,6 +159,16 @@ var MemoryServerStore = class {
15
159
  getNodeId() {
16
160
  return this.nodeId;
17
161
  }
162
+ async setSchema(schema) {
163
+ this.assertOpen();
164
+ this.schema = schema;
165
+ for (const collectionName of Object.keys(schema.collections)) {
166
+ if (!this.materializedRecords.has(collectionName)) {
167
+ this.materializedRecords.set(collectionName, /* @__PURE__ */ new Map());
168
+ }
169
+ }
170
+ this.backfillAllCollections();
171
+ }
18
172
  async applyRemoteOperation(op) {
19
173
  this.assertOpen();
20
174
  if (this.operationIndex.has(op.id)) {
@@ -26,6 +180,9 @@ var MemoryServerStore = class {
26
180
  if (op.sequenceNumber > currentSeq) {
27
181
  this.versionVector.set(op.nodeId, op.sequenceNumber);
28
182
  }
183
+ if (this.schema && this.schema.collections[op.collection]) {
184
+ this.rebuildMaterializedRecord(op.collection, op.recordId);
185
+ }
29
186
  return "applied";
30
187
  }
31
188
  async getOperationRange(nodeId, fromSeq, toSeq) {
@@ -38,6 +195,114 @@ var MemoryServerStore = class {
38
195
  this.assertOpen();
39
196
  return this.operations.length;
40
197
  }
198
+ async materializeCollection(collection) {
199
+ this.assertOpen();
200
+ if (this.schema && this.schema.collections[collection]) {
201
+ return this.queryCollection(collection);
202
+ }
203
+ return this.materializeFromOps(collection);
204
+ }
205
+ async queryCollection(collection, options) {
206
+ this.assertOpen();
207
+ this.assertSchema();
208
+ this.assertCollection(collection);
209
+ if (options?.where) {
210
+ for (const key of Object.keys(options.where)) {
211
+ validateFieldName(collection, key, this.schema);
212
+ }
213
+ }
214
+ if (options?.orderBy) {
215
+ validateFieldName(collection, options.orderBy, this.schema);
216
+ }
217
+ const collectionMap = this.materializedRecords.get(collection);
218
+ if (!collectionMap) return [];
219
+ let records = Array.from(collectionMap.values()).filter((r) => {
220
+ if (!options?.includeDeleted && r._deleted === 1) return false;
221
+ return true;
222
+ });
223
+ if (options?.where) {
224
+ for (const [key, value] of Object.entries(options.where)) {
225
+ records = records.filter((r) => r[key] === value);
226
+ }
227
+ }
228
+ if (options?.orderBy) {
229
+ const field = options.orderBy;
230
+ const dir = options.orderDirection === "desc" ? -1 : 1;
231
+ records.sort((a, b) => {
232
+ const aVal = a[field];
233
+ const bVal = b[field];
234
+ if (aVal === bVal) return 0;
235
+ if (aVal === null || aVal === void 0) return 1;
236
+ if (bVal === null || bVal === void 0) return -1;
237
+ return aVal < bVal ? -1 * dir : 1 * dir;
238
+ });
239
+ }
240
+ if (options?.offset !== void 0) {
241
+ records = records.slice(options.offset);
242
+ }
243
+ if (options?.limit !== void 0) {
244
+ records = records.slice(0, options.limit);
245
+ }
246
+ return records.map((r) => {
247
+ const clean = { id: r.id };
248
+ const collectionDef = this.schema.collections[collection];
249
+ for (const fieldName of Object.keys(collectionDef.fields)) {
250
+ if (fieldName in r) {
251
+ clean[fieldName] = r[fieldName];
252
+ }
253
+ }
254
+ if ("_created_at" in r) clean._created_at = r._created_at;
255
+ if ("_updated_at" in r) clean._updated_at = r._updated_at;
256
+ return clean;
257
+ });
258
+ }
259
+ async findRecord(collection, id) {
260
+ this.assertOpen();
261
+ this.assertSchema();
262
+ this.assertCollection(collection);
263
+ const collectionMap = this.materializedRecords.get(collection);
264
+ if (!collectionMap) return null;
265
+ const record = collectionMap.get(id);
266
+ if (!record || record._deleted === 1) return null;
267
+ const clean = { id: record.id };
268
+ const collectionDef = this.schema.collections[collection];
269
+ for (const fieldName of Object.keys(collectionDef.fields)) {
270
+ if (fieldName in record) {
271
+ clean[fieldName] = record[fieldName];
272
+ }
273
+ }
274
+ if ("_created_at" in record) clean._created_at = record._created_at;
275
+ if ("_updated_at" in record) clean._updated_at = record._updated_at;
276
+ return clean;
277
+ }
278
+ async countCollection(collection, where) {
279
+ this.assertOpen();
280
+ this.assertSchema();
281
+ this.assertCollection(collection);
282
+ if (where) {
283
+ for (const key of Object.keys(where)) {
284
+ validateFieldName(collection, key, this.schema);
285
+ }
286
+ }
287
+ const collectionMap = this.materializedRecords.get(collection);
288
+ if (!collectionMap) return 0;
289
+ let count3 = 0;
290
+ for (const record of collectionMap.values()) {
291
+ if (record._deleted === 1) continue;
292
+ if (where) {
293
+ let matches = true;
294
+ for (const [key, value] of Object.entries(where)) {
295
+ if (record[key] !== value) {
296
+ matches = false;
297
+ break;
298
+ }
299
+ }
300
+ if (!matches) continue;
301
+ }
302
+ count3++;
303
+ }
304
+ return count3;
305
+ }
41
306
  async close() {
42
307
  this.closed = true;
43
308
  }
@@ -48,11 +313,124 @@ var MemoryServerStore = class {
48
313
  getAllOperations() {
49
314
  return [...this.operations];
50
315
  }
316
+ // ---------------------------------------------------------------------------
317
+ // Materialization internals
318
+ // ---------------------------------------------------------------------------
319
+ rebuildMaterializedRecord(collection, recordId) {
320
+ const collectionDef = this.schema.collections[collection];
321
+ if (!collectionDef) return;
322
+ let collectionMap = this.materializedRecords.get(collection);
323
+ if (!collectionMap) {
324
+ collectionMap = /* @__PURE__ */ new Map();
325
+ this.materializedRecords.set(collection, collectionMap);
326
+ }
327
+ const recordOps = this.operations.filter((op) => op.collection === collection && op.recordId === recordId).sort((a, b) => {
328
+ if (a.timestamp.wallTime !== b.timestamp.wallTime) return a.timestamp.wallTime - b.timestamp.wallTime;
329
+ if (a.timestamp.logical !== b.timestamp.logical) return a.timestamp.logical - b.timestamp.logical;
330
+ return a.sequenceNumber - b.sequenceNumber;
331
+ });
332
+ const parsedOps = recordOps.map((op) => ({
333
+ type: op.type,
334
+ data: op.data
335
+ }));
336
+ const recordData = replayOperationsForRecord(parsedOps);
337
+ if (recordData) {
338
+ const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
339
+ const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
340
+ const materialized = {
341
+ id: recordId,
342
+ ...recordData,
343
+ _created_at: createdAt,
344
+ _updated_at: updatedAt,
345
+ _deleted: 0
346
+ };
347
+ collectionMap.set(recordId, materialized);
348
+ } else {
349
+ const existing = collectionMap.get(recordId);
350
+ if (existing) {
351
+ existing._deleted = 1;
352
+ existing._updated_at = Date.now();
353
+ } else {
354
+ collectionMap.set(recordId, {
355
+ id: recordId,
356
+ _deleted: 1,
357
+ _created_at: Date.now(),
358
+ _updated_at: Date.now()
359
+ });
360
+ }
361
+ }
362
+ }
363
+ backfillAllCollections() {
364
+ if (!this.schema) return;
365
+ const recordKeys = /* @__PURE__ */ new Set();
366
+ for (const op of this.operations) {
367
+ if (this.schema.collections[op.collection]) {
368
+ recordKeys.add(`${op.collection}:::${op.recordId}`);
369
+ }
370
+ }
371
+ for (const key of recordKeys) {
372
+ const [collection, recordId] = key.split(":::");
373
+ this.rebuildMaterializedRecord(collection, recordId);
374
+ }
375
+ }
376
+ // ---------------------------------------------------------------------------
377
+ // Fallback materialization (no schema)
378
+ // ---------------------------------------------------------------------------
379
+ materializeFromOps(collection) {
380
+ const collectionOps = this.operations.filter((op) => op.collection === collection).sort((a, b) => {
381
+ if (a.timestamp.wallTime !== b.timestamp.wallTime) return a.timestamp.wallTime - b.timestamp.wallTime;
382
+ if (a.timestamp.logical !== b.timestamp.logical) return a.timestamp.logical - b.timestamp.logical;
383
+ return a.sequenceNumber - b.sequenceNumber;
384
+ });
385
+ const records = /* @__PURE__ */ new Map();
386
+ const deleted = /* @__PURE__ */ new Set();
387
+ for (const op of collectionOps) {
388
+ switch (op.type) {
389
+ case "insert":
390
+ if (op.data) {
391
+ records.set(op.recordId, { id: op.recordId, ...op.data });
392
+ deleted.delete(op.recordId);
393
+ }
394
+ break;
395
+ case "update":
396
+ if (op.data) {
397
+ const existing = records.get(op.recordId) ?? { id: op.recordId };
398
+ records.set(op.recordId, { ...existing, ...op.data });
399
+ deleted.delete(op.recordId);
400
+ }
401
+ break;
402
+ case "delete":
403
+ deleted.add(op.recordId);
404
+ break;
405
+ }
406
+ }
407
+ for (const id of deleted) {
408
+ records.delete(id);
409
+ }
410
+ return Array.from(records.values());
411
+ }
412
+ // ---------------------------------------------------------------------------
413
+ // Assertions
414
+ // ---------------------------------------------------------------------------
51
415
  assertOpen() {
52
416
  if (this.closed) {
53
417
  throw new Error("MemoryServerStore is closed");
54
418
  }
55
419
  }
420
+ assertSchema() {
421
+ if (!this.schema) {
422
+ throw new Error(
423
+ "Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
424
+ );
425
+ }
426
+ }
427
+ assertCollection(collection) {
428
+ if (!this.schema.collections[collection]) {
429
+ throw new Error(
430
+ `Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
431
+ );
432
+ }
433
+ }
56
434
  };
57
435
 
58
436
  // src/store/postgres-server-store.ts
@@ -100,6 +478,7 @@ var PostgresServerStore = class {
100
478
  db;
101
479
  versionVector = /* @__PURE__ */ new Map();
102
480
  ready;
481
+ schema = null;
103
482
  closed = false;
104
483
  constructor(db, nodeId) {
105
484
  this.db = db;
@@ -113,6 +492,29 @@ var PostgresServerStore = class {
113
492
  getNodeId() {
114
493
  return this.nodeId;
115
494
  }
495
+ async setSchema(schema) {
496
+ this.assertOpen();
497
+ await this.ready;
498
+ this.schema = schema;
499
+ const ddlStatements = generateAllCollectionDDL(schema, "postgres");
500
+ for (const stmt of ddlStatements) {
501
+ if (stmt.startsWith("--kora:safe-alter")) {
502
+ const alterSql = stmt.replace("--kora:safe-alter\n", "");
503
+ try {
504
+ await this.db.execute(sql.raw(alterSql));
505
+ } catch (e) {
506
+ const msg = e instanceof Error ? e.message : "";
507
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
508
+ if (!msg.includes("already exists") && !msg.includes("duplicate column") && !causeMsg.includes("already exists") && !causeMsg.includes("duplicate column")) {
509
+ throw e;
510
+ }
511
+ }
512
+ } else {
513
+ await this.db.execute(sql.raw(stmt));
514
+ }
515
+ }
516
+ await this.backfillAllCollections();
517
+ }
116
518
  async applyRemoteOperation(op) {
117
519
  this.assertOpen();
118
520
  await this.ready;
@@ -135,6 +537,9 @@ var PostgresServerStore = class {
135
537
  lastSeenAt: sql`${now}`
136
538
  }
137
539
  });
540
+ if (this.schema && this.schema.collections[op.collection]) {
541
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
542
+ }
138
543
  });
139
544
  const currentMax = this.versionVector.get(op.nodeId) ?? 0;
140
545
  if (op.sequenceNumber > currentMax) {
@@ -159,9 +564,291 @@ var PostgresServerStore = class {
159
564
  const result = await this.db.select({ value: count() }).from(pgOperations);
160
565
  return result[0]?.value ?? 0;
161
566
  }
567
+ async materializeCollection(collection) {
568
+ this.assertOpen();
569
+ await this.ready;
570
+ if (this.schema && this.schema.collections[collection]) {
571
+ return this.queryCollection(collection);
572
+ }
573
+ return this.materializeFromOpsLog(collection);
574
+ }
575
+ async queryCollection(collection, options) {
576
+ this.assertOpen();
577
+ await this.ready;
578
+ this.assertSchema();
579
+ this.assertCollection(collection);
580
+ const collectionDef = this.schema.collections[collection];
581
+ if (options?.where) {
582
+ for (const key of Object.keys(options.where)) {
583
+ validateFieldName(collection, key, this.schema);
584
+ }
585
+ }
586
+ if (options?.orderBy) {
587
+ validateFieldName(collection, options.orderBy, this.schema);
588
+ }
589
+ const query = this.buildSelectQuery(collection, options);
590
+ const rows = await this.db.execute(query);
591
+ return rows.map((row) => this.deserializeRow(row, collectionDef));
592
+ }
593
+ async findRecord(collection, id) {
594
+ this.assertOpen();
595
+ await this.ready;
596
+ this.assertSchema();
597
+ this.assertCollection(collection);
598
+ const collectionDef = this.schema.collections[collection];
599
+ const query = sql`SELECT * FROM ${sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
600
+ const rows = await this.db.execute(query);
601
+ if (rows.length === 0) return null;
602
+ return this.deserializeRow(rows[0], collectionDef);
603
+ }
604
+ async countCollection(collection, where) {
605
+ this.assertOpen();
606
+ await this.ready;
607
+ this.assertSchema();
608
+ this.assertCollection(collection);
609
+ if (where) {
610
+ for (const key of Object.keys(where)) {
611
+ validateFieldName(collection, key, this.schema);
612
+ }
613
+ }
614
+ const whereClause = this.buildWhereClause(where ?? {}, false);
615
+ const query = sql`SELECT COUNT(*) as cnt FROM ${sql.raw(collection)} WHERE ${whereClause}`;
616
+ const rows = await this.db.execute(query);
617
+ const cnt = rows[0]?.cnt;
618
+ return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
619
+ }
162
620
  async close() {
163
621
  this.closed = true;
164
622
  }
623
+ // ---------------------------------------------------------------------------
624
+ // Materialization internals
625
+ // ---------------------------------------------------------------------------
626
+ /**
627
+ * Rebuild a single record in the materialized collection table by replaying
628
+ * all operations for that record.
629
+ */
630
+ async rebuildMaterializedRecord(txOrDb, collection, recordId) {
631
+ const collectionDef = this.schema.collections[collection];
632
+ if (!collectionDef) return;
633
+ const ops = await txOrDb.select({
634
+ type: pgOperations.type,
635
+ data: pgOperations.data,
636
+ wallTime: pgOperations.wallTime
637
+ }).from(pgOperations).where(
638
+ and(
639
+ eq(pgOperations.collection, collection),
640
+ eq(pgOperations.recordId, recordId)
641
+ )
642
+ ).orderBy(
643
+ asc(pgOperations.wallTime),
644
+ asc(pgOperations.logical),
645
+ asc(pgOperations.sequenceNumber)
646
+ );
647
+ const parsedOps = ops.map((op) => ({
648
+ type: op.type,
649
+ data: op.data !== null ? JSON.parse(op.data) : null
650
+ }));
651
+ const recordData = replayOperationsForRecord(parsedOps);
652
+ const fieldNames = Object.keys(collectionDef.fields);
653
+ if (recordData) {
654
+ const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
655
+ const updatedAt = ops.length > 0 ? ops[ops.length - 1].wallTime : Date.now();
656
+ await this.upsertMaterializedRecord(
657
+ txOrDb,
658
+ collection,
659
+ recordId,
660
+ recordData,
661
+ fieldNames,
662
+ collectionDef,
663
+ createdAt,
664
+ updatedAt
665
+ );
666
+ } else {
667
+ await txOrDb.execute(
668
+ sql`UPDATE ${sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
669
+ );
670
+ }
671
+ }
672
+ /**
673
+ * UPSERT a record into the materialized collection table.
674
+ */
675
+ async upsertMaterializedRecord(txOrDb, tableName, recordId, recordData, fieldNames, collectionDef, createdAt, updatedAt) {
676
+ const allColumns = ["id", ...fieldNames, "_created_at", "_updated_at", "_deleted"];
677
+ const values = [
678
+ recordId,
679
+ ...fieldNames.map((f) => {
680
+ const descriptor = collectionDef.fields[f];
681
+ return descriptor ? serializeFieldValue(recordData[f] ?? null, descriptor) : null;
682
+ }),
683
+ createdAt,
684
+ updatedAt,
685
+ 0
686
+ ];
687
+ const columnsSql = sql.raw(allColumns.join(", "));
688
+ const valuesSql = sql.join(
689
+ values.map((v) => sql`${v}`),
690
+ sql.raw(", ")
691
+ );
692
+ const updateSet = sql.raw(
693
+ allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
694
+ );
695
+ await txOrDb.execute(
696
+ sql`INSERT INTO ${sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
697
+ );
698
+ }
699
+ /**
700
+ * Backfill all materialized collection tables from the existing operation log.
701
+ */
702
+ async backfillAllCollections() {
703
+ if (!this.schema) return;
704
+ for (const collectionName of Object.keys(this.schema.collections)) {
705
+ await this.backfillCollection(collectionName);
706
+ }
707
+ }
708
+ /**
709
+ * Backfill a single collection's materialized table from operations.
710
+ */
711
+ async backfillCollection(collectionName) {
712
+ const collectionDef = this.schema.collections[collectionName];
713
+ if (!collectionDef) return;
714
+ const allOps = await this.db.select({
715
+ recordId: pgOperations.recordId,
716
+ type: pgOperations.type,
717
+ data: pgOperations.data,
718
+ wallTime: pgOperations.wallTime
719
+ }).from(pgOperations).where(eq(pgOperations.collection, collectionName)).orderBy(
720
+ asc(pgOperations.wallTime),
721
+ asc(pgOperations.logical),
722
+ asc(pgOperations.sequenceNumber)
723
+ );
724
+ if (allOps.length === 0) return;
725
+ const grouped = /* @__PURE__ */ new Map();
726
+ for (const op of allOps) {
727
+ let group = grouped.get(op.recordId);
728
+ if (!group) {
729
+ group = [];
730
+ grouped.set(op.recordId, group);
731
+ }
732
+ group.push(op);
733
+ }
734
+ const fieldNames = Object.keys(collectionDef.fields);
735
+ for (const [recordId, recordOps] of grouped) {
736
+ const parsedOps = recordOps.map((op) => ({
737
+ type: op.type,
738
+ data: op.data !== null ? JSON.parse(op.data) : null
739
+ }));
740
+ const recordData = replayOperationsForRecord(parsedOps);
741
+ if (recordData) {
742
+ const createdAt = recordOps[0].wallTime;
743
+ const updatedAt = recordOps[recordOps.length - 1].wallTime;
744
+ await this.upsertMaterializedRecord(
745
+ this.db,
746
+ collectionName,
747
+ recordId,
748
+ recordData,
749
+ fieldNames,
750
+ collectionDef,
751
+ createdAt,
752
+ updatedAt
753
+ );
754
+ } else {
755
+ await this.db.execute(
756
+ sql`INSERT INTO ${sql.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
757
+ );
758
+ }
759
+ }
760
+ }
761
+ // ---------------------------------------------------------------------------
762
+ // Query building
763
+ // ---------------------------------------------------------------------------
764
+ buildSelectQuery(collection, options) {
765
+ const whereClause = this.buildWhereClause(
766
+ options?.where ?? {},
767
+ options?.includeDeleted ?? false
768
+ );
769
+ const parts = [
770
+ sql`SELECT * FROM ${sql.raw(collection)} WHERE ${whereClause}`
771
+ ];
772
+ if (options?.orderBy) {
773
+ const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
774
+ parts.push(sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
775
+ }
776
+ if (options?.limit !== void 0) {
777
+ parts.push(sql` LIMIT ${options.limit}`);
778
+ }
779
+ if (options?.offset !== void 0) {
780
+ parts.push(sql` OFFSET ${options.offset}`);
781
+ }
782
+ return sql.join(parts, sql.raw(""));
783
+ }
784
+ buildWhereClause(where, includeDeleted) {
785
+ const conditions = [];
786
+ if (!includeDeleted) {
787
+ conditions.push(sql.raw("_deleted = 0"));
788
+ }
789
+ for (const [key, value] of Object.entries(where)) {
790
+ conditions.push(sql`${sql.raw(key)} = ${value}`);
791
+ }
792
+ if (conditions.length === 0) {
793
+ return sql.raw("1 = 1");
794
+ }
795
+ return sql.join(conditions, sql.raw(" AND "));
796
+ }
797
+ // ---------------------------------------------------------------------------
798
+ // Row deserialization
799
+ // ---------------------------------------------------------------------------
800
+ deserializeRow(row, collectionDef) {
801
+ const record = { id: row.id };
802
+ for (const [fieldName, descriptor] of Object.entries(collectionDef.fields)) {
803
+ if (fieldName in row) {
804
+ record[fieldName] = deserializeFieldValue(row[fieldName], descriptor);
805
+ }
806
+ }
807
+ if ("_created_at" in row) record._created_at = row._created_at;
808
+ if ("_updated_at" in row) record._updated_at = row._updated_at;
809
+ return record;
810
+ }
811
+ // ---------------------------------------------------------------------------
812
+ // Fallback materialization (operation replay, no schema)
813
+ // ---------------------------------------------------------------------------
814
+ async materializeFromOpsLog(collection) {
815
+ const rows = await this.db.select().from(pgOperations).where(eq(pgOperations.collection, collection)).orderBy(
816
+ asc(pgOperations.wallTime),
817
+ asc(pgOperations.logical),
818
+ asc(pgOperations.sequenceNumber)
819
+ );
820
+ const records = /* @__PURE__ */ new Map();
821
+ const deleted = /* @__PURE__ */ new Set();
822
+ for (const row of rows) {
823
+ const recordId = row.recordId;
824
+ const data = row.data !== null ? JSON.parse(row.data) : null;
825
+ switch (row.type) {
826
+ case "insert":
827
+ if (data) {
828
+ records.set(recordId, { id: recordId, ...data });
829
+ deleted.delete(recordId);
830
+ }
831
+ break;
832
+ case "update":
833
+ if (data) {
834
+ const existing = records.get(recordId) ?? { id: recordId };
835
+ records.set(recordId, { ...existing, ...data });
836
+ deleted.delete(recordId);
837
+ }
838
+ break;
839
+ case "delete":
840
+ deleted.add(recordId);
841
+ break;
842
+ }
843
+ }
844
+ for (const id of deleted) {
845
+ records.delete(id);
846
+ }
847
+ return Array.from(records.values());
848
+ }
849
+ // ---------------------------------------------------------------------------
850
+ // Initialization
851
+ // ---------------------------------------------------------------------------
165
852
  async initialize() {
166
853
  await this.ensureTables();
167
854
  const rows = await this.db.select({
@@ -172,10 +859,6 @@ var PostgresServerStore = class {
172
859
  this.versionVector.set(row.nodeId, row.maxSequenceNumber);
173
860
  }
174
861
  }
175
- /**
176
- * Create tables if they don't exist.
177
- * Uses raw SQL via Drizzle's sql template — standard DDL practice without drizzle-kit.
178
- */
179
862
  async ensureTables() {
180
863
  await this.db.execute(sql`
181
864
  CREATE TABLE IF NOT EXISTS operations (
@@ -204,6 +887,9 @@ var PostgresServerStore = class {
204
887
  await this.db.execute(
205
888
  sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`
206
889
  );
890
+ await this.db.execute(
891
+ sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
892
+ );
207
893
  await this.db.execute(sql`
208
894
  CREATE TABLE IF NOT EXISTS sync_state (
209
895
  node_id TEXT PRIMARY KEY,
@@ -212,6 +898,9 @@ var PostgresServerStore = class {
212
898
  )
213
899
  `);
214
900
  }
901
+ // ---------------------------------------------------------------------------
902
+ // Operation serialization
903
+ // ---------------------------------------------------------------------------
215
904
  serializeOperation(op, receivedAt) {
216
905
  return {
217
906
  id: op.id,
@@ -249,11 +938,28 @@ var PostgresServerStore = class {
249
938
  schemaVersion: row.schemaVersion
250
939
  };
251
940
  }
941
+ // ---------------------------------------------------------------------------
942
+ // Assertions
943
+ // ---------------------------------------------------------------------------
252
944
  assertOpen() {
253
945
  if (this.closed) {
254
946
  throw new Error("PostgresServerStore is closed");
255
947
  }
256
948
  }
949
+ assertSchema() {
950
+ if (!this.schema) {
951
+ throw new Error(
952
+ "Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
953
+ );
954
+ }
955
+ }
956
+ assertCollection(collection) {
957
+ if (!this.schema.collections[collection]) {
958
+ throw new Error(
959
+ `Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
960
+ );
961
+ }
962
+ }
257
963
  };
258
964
  async function createPostgresServerStore(options) {
259
965
  const { postgresClient, drizzleFn } = await loadPostgresDeps();
@@ -322,6 +1028,7 @@ var esmRequire = createRequire(import.meta.url);
322
1028
  var SqliteServerStore = class {
323
1029
  nodeId;
324
1030
  db;
1031
+ schema = null;
325
1032
  closed = false;
326
1033
  constructor(db, nodeId) {
327
1034
  this.db = db;
@@ -340,6 +1047,28 @@ var SqliteServerStore = class {
340
1047
  getNodeId() {
341
1048
  return this.nodeId;
342
1049
  }
1050
+ async setSchema(schema) {
1051
+ this.assertOpen();
1052
+ this.schema = schema;
1053
+ const ddlStatements = generateAllCollectionDDL(schema, "sqlite");
1054
+ for (const stmt of ddlStatements) {
1055
+ if (stmt.startsWith("--kora:safe-alter")) {
1056
+ const alterSql = stmt.replace("--kora:safe-alter\n", "");
1057
+ try {
1058
+ this.db.run(sql2.raw(alterSql));
1059
+ } catch (e) {
1060
+ const msg = e instanceof Error ? e.message : "";
1061
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
1062
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
1063
+ throw e;
1064
+ }
1065
+ }
1066
+ } else {
1067
+ this.db.run(sql2.raw(stmt));
1068
+ }
1069
+ }
1070
+ await this.backfillAllCollections();
1071
+ }
343
1072
  async applyRemoteOperation(op) {
344
1073
  this.assertOpen();
345
1074
  const now = Date.now();
@@ -360,6 +1089,9 @@ var SqliteServerStore = class {
360
1089
  lastSeenAt: sql2`${now}`
361
1090
  }
362
1091
  }).run();
1092
+ if (this.schema && this.schema.collections[op.collection]) {
1093
+ this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
1094
+ }
363
1095
  return "applied";
364
1096
  });
365
1097
  return result;
@@ -374,12 +1106,282 @@ var SqliteServerStore = class {
374
1106
  const result = this.db.select({ value: count2() }).from(operations).all();
375
1107
  return result[0]?.value ?? 0;
376
1108
  }
1109
+ async materializeCollection(collection) {
1110
+ this.assertOpen();
1111
+ if (this.schema && this.schema.collections[collection]) {
1112
+ return this.queryCollection(collection);
1113
+ }
1114
+ return this.materializeFromOpsLog(collection);
1115
+ }
1116
+ async queryCollection(collection, options) {
1117
+ this.assertOpen();
1118
+ this.assertSchema();
1119
+ this.assertCollection(collection);
1120
+ const collectionDef = this.schema.collections[collection];
1121
+ if (options?.where) {
1122
+ for (const key of Object.keys(options.where)) {
1123
+ validateFieldName(collection, key, this.schema);
1124
+ }
1125
+ }
1126
+ if (options?.orderBy) {
1127
+ validateFieldName(collection, options.orderBy, this.schema);
1128
+ }
1129
+ const query = this.buildSelectQuery(collection, options);
1130
+ const rows = this.db.all(query);
1131
+ return rows.map((row) => this.deserializeRow(row, collectionDef));
1132
+ }
1133
+ async findRecord(collection, id) {
1134
+ this.assertOpen();
1135
+ this.assertSchema();
1136
+ this.assertCollection(collection);
1137
+ const collectionDef = this.schema.collections[collection];
1138
+ const query = sql2`SELECT * FROM ${sql2.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1139
+ const rows = this.db.all(query);
1140
+ if (rows.length === 0) return null;
1141
+ return this.deserializeRow(rows[0], collectionDef);
1142
+ }
1143
+ async countCollection(collection, where) {
1144
+ this.assertOpen();
1145
+ this.assertSchema();
1146
+ this.assertCollection(collection);
1147
+ if (where) {
1148
+ for (const key of Object.keys(where)) {
1149
+ validateFieldName(collection, key, this.schema);
1150
+ }
1151
+ }
1152
+ const whereClause = this.buildWhereClause(where ?? {}, false);
1153
+ const query = sql2`SELECT COUNT(*) as cnt FROM ${sql2.raw(collection)} WHERE ${whereClause}`;
1154
+ const rows = this.db.all(query);
1155
+ return rows[0]?.cnt ?? 0;
1156
+ }
377
1157
  async close() {
378
1158
  this.closed = true;
379
1159
  }
1160
+ // ---------------------------------------------------------------------------
1161
+ // Materialization internals
1162
+ // ---------------------------------------------------------------------------
1163
+ /**
1164
+ * Rebuild a single record in the materialized collection table by replaying
1165
+ * all operations for that record. Called within the applyRemoteOperation
1166
+ * transaction for atomic dual-write.
1167
+ */
1168
+ rebuildMaterializedRecord(txOrDb, collection, recordId) {
1169
+ const collectionDef = this.schema.collections[collection];
1170
+ if (!collectionDef) return;
1171
+ const ops = txOrDb.select({
1172
+ type: operations.type,
1173
+ data: operations.data,
1174
+ wallTime: operations.wallTime
1175
+ }).from(operations).where(
1176
+ and2(
1177
+ eq2(operations.collection, collection),
1178
+ eq2(operations.recordId, recordId)
1179
+ )
1180
+ ).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
1181
+ const parsedOps = ops.map((op) => ({
1182
+ type: op.type,
1183
+ data: op.data !== null ? JSON.parse(op.data) : null
1184
+ }));
1185
+ const recordData = replayOperationsForRecord(parsedOps);
1186
+ const fieldNames = Object.keys(collectionDef.fields);
1187
+ if (recordData) {
1188
+ const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
1189
+ const updatedAt = ops.length > 0 ? ops[ops.length - 1].wallTime : Date.now();
1190
+ this.upsertMaterializedRecord(
1191
+ txOrDb,
1192
+ collection,
1193
+ recordId,
1194
+ recordData,
1195
+ fieldNames,
1196
+ collectionDef,
1197
+ createdAt,
1198
+ updatedAt
1199
+ );
1200
+ } else {
1201
+ txOrDb.run(
1202
+ sql2`UPDATE ${sql2.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1203
+ );
1204
+ }
1205
+ }
1206
+ /**
1207
+ * UPSERT a record into the materialized collection table.
1208
+ * Uses INSERT ... ON CONFLICT (id) DO UPDATE SET for atomic upsert.
1209
+ */
1210
+ upsertMaterializedRecord(txOrDb, tableName, recordId, recordData, fieldNames, collectionDef, createdAt, updatedAt) {
1211
+ const allColumns = ["id", ...fieldNames, "_created_at", "_updated_at", "_deleted"];
1212
+ const values = [
1213
+ recordId,
1214
+ ...fieldNames.map((f) => {
1215
+ const descriptor = collectionDef.fields[f];
1216
+ return descriptor ? serializeFieldValue(recordData[f] ?? null, descriptor) : null;
1217
+ }),
1218
+ createdAt,
1219
+ updatedAt,
1220
+ 0
1221
+ // _deleted = false
1222
+ ];
1223
+ const columnsSql = sql2.raw(allColumns.join(", "));
1224
+ const valuesSql = sql2.join(
1225
+ values.map((v) => sql2`${v}`),
1226
+ sql2.raw(", ")
1227
+ );
1228
+ const updateSet = sql2.raw(
1229
+ allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1230
+ );
1231
+ txOrDb.run(
1232
+ sql2`INSERT INTO ${sql2.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1233
+ );
1234
+ }
1235
+ /**
1236
+ * Backfill all materialized collection tables from the existing operation log.
1237
+ * Called when setSchema() is invoked and operations already exist.
1238
+ */
1239
+ async backfillAllCollections() {
1240
+ if (!this.schema) return;
1241
+ for (const collectionName of Object.keys(this.schema.collections)) {
1242
+ this.backfillCollection(collectionName);
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Backfill a single collection's materialized table from operations.
1247
+ */
1248
+ backfillCollection(collectionName) {
1249
+ const collectionDef = this.schema.collections[collectionName];
1250
+ if (!collectionDef) return;
1251
+ const allOps = this.db.select({
1252
+ recordId: operations.recordId,
1253
+ type: operations.type,
1254
+ data: operations.data,
1255
+ wallTime: operations.wallTime
1256
+ }).from(operations).where(eq2(operations.collection, collectionName)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
1257
+ if (allOps.length === 0) return;
1258
+ const grouped = /* @__PURE__ */ new Map();
1259
+ for (const op of allOps) {
1260
+ let group = grouped.get(op.recordId);
1261
+ if (!group) {
1262
+ group = [];
1263
+ grouped.set(op.recordId, group);
1264
+ }
1265
+ group.push(op);
1266
+ }
1267
+ const fieldNames = Object.keys(collectionDef.fields);
1268
+ this.db.transaction((tx) => {
1269
+ for (const [recordId, recordOps] of grouped) {
1270
+ const parsedOps = recordOps.map((op) => ({
1271
+ type: op.type,
1272
+ data: op.data !== null ? JSON.parse(op.data) : null
1273
+ }));
1274
+ const recordData = replayOperationsForRecord(parsedOps);
1275
+ if (recordData) {
1276
+ const createdAt = recordOps[0].wallTime;
1277
+ const updatedAt = recordOps[recordOps.length - 1].wallTime;
1278
+ this.upsertMaterializedRecord(
1279
+ tx,
1280
+ collectionName,
1281
+ recordId,
1282
+ recordData,
1283
+ fieldNames,
1284
+ collectionDef,
1285
+ createdAt,
1286
+ updatedAt
1287
+ );
1288
+ } else {
1289
+ tx.run(
1290
+ sql2`INSERT INTO ${sql2.raw(collectionName)} (id, _deleted, _created_at, _updated_at) VALUES (${recordId}, 1, ${Date.now()}, ${Date.now()}) ON CONFLICT (id) DO UPDATE SET _deleted = 1, _updated_at = ${Date.now()}`
1291
+ );
1292
+ }
1293
+ }
1294
+ });
1295
+ }
1296
+ // ---------------------------------------------------------------------------
1297
+ // Query building
1298
+ // ---------------------------------------------------------------------------
1299
+ buildSelectQuery(collection, options) {
1300
+ const whereClause = this.buildWhereClause(
1301
+ options?.where ?? {},
1302
+ options?.includeDeleted ?? false
1303
+ );
1304
+ const parts = [
1305
+ sql2`SELECT * FROM ${sql2.raw(collection)} WHERE ${whereClause}`
1306
+ ];
1307
+ if (options?.orderBy) {
1308
+ const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1309
+ parts.push(sql2.raw(` ORDER BY ${options.orderBy} ${dir}`));
1310
+ }
1311
+ if (options?.limit !== void 0) {
1312
+ parts.push(sql2` LIMIT ${options.limit}`);
1313
+ }
1314
+ if (options?.offset !== void 0) {
1315
+ parts.push(sql2` OFFSET ${options.offset}`);
1316
+ }
1317
+ return sql2.join(parts, sql2.raw(""));
1318
+ }
1319
+ buildWhereClause(where, includeDeleted) {
1320
+ const conditions = [];
1321
+ if (!includeDeleted) {
1322
+ conditions.push(sql2.raw("_deleted = 0"));
1323
+ }
1324
+ for (const [key, value] of Object.entries(where)) {
1325
+ conditions.push(sql2`${sql2.raw(key)} = ${value}`);
1326
+ }
1327
+ if (conditions.length === 0) {
1328
+ return sql2.raw("1 = 1");
1329
+ }
1330
+ return sql2.join(conditions, sql2.raw(" AND "));
1331
+ }
1332
+ // ---------------------------------------------------------------------------
1333
+ // Row deserialization
1334
+ // ---------------------------------------------------------------------------
1335
+ deserializeRow(row, collectionDef) {
1336
+ const record = { id: row.id };
1337
+ for (const [fieldName, descriptor] of Object.entries(collectionDef.fields)) {
1338
+ if (fieldName in row) {
1339
+ record[fieldName] = deserializeFieldValue(row[fieldName], descriptor);
1340
+ }
1341
+ }
1342
+ if ("_created_at" in row) record._created_at = row._created_at;
1343
+ if ("_updated_at" in row) record._updated_at = row._updated_at;
1344
+ return record;
1345
+ }
1346
+ // ---------------------------------------------------------------------------
1347
+ // Fallback materialization (operation replay, no schema)
1348
+ // ---------------------------------------------------------------------------
1349
+ materializeFromOpsLog(collection) {
1350
+ const rows = this.db.select().from(operations).where(eq2(operations.collection, collection)).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
1351
+ const records = /* @__PURE__ */ new Map();
1352
+ const deleted = /* @__PURE__ */ new Set();
1353
+ for (const row of rows) {
1354
+ const recordId = row.recordId;
1355
+ const data = row.data !== null ? JSON.parse(row.data) : null;
1356
+ switch (row.type) {
1357
+ case "insert":
1358
+ if (data) {
1359
+ records.set(recordId, { id: recordId, ...data });
1360
+ deleted.delete(recordId);
1361
+ }
1362
+ break;
1363
+ case "update":
1364
+ if (data) {
1365
+ const existing = records.get(recordId) ?? { id: recordId };
1366
+ records.set(recordId, { ...existing, ...data });
1367
+ deleted.delete(recordId);
1368
+ }
1369
+ break;
1370
+ case "delete":
1371
+ deleted.add(recordId);
1372
+ break;
1373
+ }
1374
+ }
1375
+ for (const id of deleted) {
1376
+ records.delete(id);
1377
+ }
1378
+ return Array.from(records.values());
1379
+ }
1380
+ // ---------------------------------------------------------------------------
1381
+ // Table setup
1382
+ // ---------------------------------------------------------------------------
380
1383
  /**
381
1384
  * Create the operations and sync_state tables if they don't exist.
382
- * Uses raw SQL via Drizzle's sql template — standard practice for DDL without drizzle-kit.
383
1385
  */
384
1386
  ensureTables() {
385
1387
  this.db.run(sql2`
@@ -409,6 +1411,9 @@ var SqliteServerStore = class {
409
1411
  this.db.run(sql2`
410
1412
  CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)
411
1413
  `);
1414
+ this.db.run(sql2`
1415
+ CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)
1416
+ `);
412
1417
  this.db.run(sql2`
413
1418
  CREATE TABLE IF NOT EXISTS sync_state (
414
1419
  node_id TEXT PRIMARY KEY,
@@ -417,6 +1422,9 @@ var SqliteServerStore = class {
417
1422
  )
418
1423
  `);
419
1424
  }
1425
+ // ---------------------------------------------------------------------------
1426
+ // Operation serialization
1427
+ // ---------------------------------------------------------------------------
420
1428
  serializeOperation(op, receivedAt) {
421
1429
  return {
422
1430
  id: op.id,
@@ -454,11 +1462,28 @@ var SqliteServerStore = class {
454
1462
  schemaVersion: row.schemaVersion
455
1463
  };
456
1464
  }
1465
+ // ---------------------------------------------------------------------------
1466
+ // Assertions
1467
+ // ---------------------------------------------------------------------------
457
1468
  assertOpen() {
458
1469
  if (this.closed) {
459
1470
  throw new Error("SqliteServerStore is closed");
460
1471
  }
461
1472
  }
1473
+ assertSchema() {
1474
+ if (!this.schema) {
1475
+ throw new Error(
1476
+ "Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
1477
+ );
1478
+ }
1479
+ }
1480
+ assertCollection(collection) {
1481
+ if (!this.schema.collections[collection]) {
1482
+ throw new Error(
1483
+ `Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
1484
+ );
1485
+ }
1486
+ }
462
1487
  };
463
1488
  function createSqliteServerStore(options) {
464
1489
  const Database = esmRequire("better-sqlite3");