@korajs/server 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -53,11 +53,155 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
53
53
 
54
54
  // src/store/memory-server-store.ts
55
55
  var import_core = require("@korajs/core");
56
+
57
+ // src/store/materialization.ts
58
+ function fieldTypeToSql(descriptor, dialect) {
59
+ switch (descriptor.kind) {
60
+ case "string":
61
+ return "TEXT";
62
+ case "number":
63
+ return dialect === "postgres" ? "DOUBLE PRECISION" : "REAL";
64
+ case "boolean":
65
+ return "INTEGER";
66
+ case "enum":
67
+ return "TEXT";
68
+ case "timestamp":
69
+ return dialect === "postgres" ? "BIGINT" : "INTEGER";
70
+ case "array":
71
+ return dialect === "postgres" ? "JSONB" : "TEXT";
72
+ case "richtext":
73
+ return dialect === "postgres" ? "BYTEA" : "BLOB";
74
+ }
75
+ }
76
+ function sqlDefaultLiteral(value) {
77
+ if (value === null) return "NULL";
78
+ if (typeof value === "string") return `'${value}'`;
79
+ if (typeof value === "number") return String(value);
80
+ if (typeof value === "boolean") return value ? "1" : "0";
81
+ return `'${JSON.stringify(value)}'`;
82
+ }
83
+ function generateCollectionDDL(name, collection, dialect) {
84
+ const statements = [];
85
+ const columns = ["id TEXT PRIMARY KEY NOT NULL"];
86
+ for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
87
+ const sqlType = fieldTypeToSql(descriptor, dialect);
88
+ let colDef = `${fieldName} ${sqlType}`;
89
+ if (descriptor.defaultValue !== void 0) {
90
+ colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
91
+ }
92
+ if (descriptor.kind === "enum" && descriptor.enumValues) {
93
+ const values = descriptor.enumValues.map((v) => `'${v}'`).join(", ");
94
+ colDef += ` CHECK (${fieldName} IN (${values}))`;
95
+ }
96
+ columns.push(colDef);
97
+ }
98
+ const tsType = dialect === "postgres" ? "BIGINT" : "INTEGER";
99
+ columns.push(`_created_at ${tsType} NOT NULL DEFAULT 0`);
100
+ columns.push(`_updated_at ${tsType} NOT NULL DEFAULT 0`);
101
+ columns.push("_deleted INTEGER NOT NULL DEFAULT 0");
102
+ statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
103
+ ${columns.join(",\n ")}
104
+ )`);
105
+ for (const [fieldName, descriptor] of Object.entries(collection.fields)) {
106
+ const sqlType = fieldTypeToSql(descriptor, dialect);
107
+ let colDef = `${fieldName} ${sqlType}`;
108
+ if (descriptor.defaultValue !== void 0) {
109
+ colDef += ` DEFAULT ${sqlDefaultLiteral(descriptor.defaultValue)}`;
110
+ }
111
+ statements.push(`--kora:safe-alter
112
+ ALTER TABLE ${name} ADD COLUMN ${colDef}`);
113
+ }
114
+ for (const indexField of collection.indexes) {
115
+ statements.push(
116
+ `CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
117
+ );
118
+ }
119
+ statements.push(
120
+ `CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`
121
+ );
122
+ return statements;
123
+ }
124
+ function generateAllCollectionDDL(schema, dialect) {
125
+ const statements = [];
126
+ for (const [name, collection] of Object.entries(schema.collections)) {
127
+ statements.push(...generateCollectionDDL(name, collection, dialect));
128
+ }
129
+ return statements;
130
+ }
131
+ function replayOperationsForRecord(ops) {
132
+ let record = null;
133
+ let deleted = false;
134
+ for (const op of ops) {
135
+ switch (op.type) {
136
+ case "insert":
137
+ if (op.data) {
138
+ record = { ...op.data };
139
+ deleted = false;
140
+ }
141
+ break;
142
+ case "update":
143
+ if (op.data) {
144
+ record = { ...record ?? {}, ...op.data };
145
+ deleted = false;
146
+ }
147
+ break;
148
+ case "delete":
149
+ deleted = true;
150
+ break;
151
+ }
152
+ }
153
+ return deleted ? null : record;
154
+ }
155
+ function serializeFieldValue(value, descriptor) {
156
+ if (value === null || value === void 0) return null;
157
+ switch (descriptor.kind) {
158
+ case "array":
159
+ return typeof value === "string" ? value : JSON.stringify(value);
160
+ case "boolean":
161
+ return value ? 1 : 0;
162
+ default:
163
+ return value;
164
+ }
165
+ }
166
+ function deserializeFieldValue(value, descriptor) {
167
+ if (value === null || value === void 0) return null;
168
+ switch (descriptor.kind) {
169
+ case "array":
170
+ return typeof value === "string" ? JSON.parse(value) : value;
171
+ case "boolean":
172
+ return value === 1 || value === true;
173
+ default:
174
+ return value;
175
+ }
176
+ }
177
+ function validateFieldName(collectionName, fieldName, schema) {
178
+ const collection = schema.collections[collectionName];
179
+ if (!collection) {
180
+ throw new Error(`Unknown collection: ${collectionName}`);
181
+ }
182
+ const validFields = /* @__PURE__ */ new Set([
183
+ "id",
184
+ "_created_at",
185
+ "_updated_at",
186
+ "_deleted",
187
+ ...Object.keys(collection.fields)
188
+ ]);
189
+ if (!validFields.has(fieldName)) {
190
+ throw new Error(
191
+ `Invalid field name "${fieldName}" for collection "${collectionName}". Valid fields: ${Array.from(validFields).join(", ")}`
192
+ );
193
+ }
194
+ }
195
+
196
+ // src/store/memory-server-store.ts
56
197
  var MemoryServerStore = class {
57
198
  nodeId;
58
199
  operations = [];
59
200
  operationIndex = /* @__PURE__ */ new Map();
60
201
  versionVector = /* @__PURE__ */ new Map();
202
+ schema = null;
203
+ /** Materialized records: collection -> recordId -> record data */
204
+ materializedRecords = /* @__PURE__ */ new Map();
61
205
  closed = false;
62
206
  constructor(nodeId) {
63
207
  this.nodeId = nodeId ?? (0, import_core.generateUUIDv7)();
@@ -68,6 +212,16 @@ var MemoryServerStore = class {
68
212
  getNodeId() {
69
213
  return this.nodeId;
70
214
  }
215
+ async setSchema(schema) {
216
+ this.assertOpen();
217
+ this.schema = schema;
218
+ for (const collectionName of Object.keys(schema.collections)) {
219
+ if (!this.materializedRecords.has(collectionName)) {
220
+ this.materializedRecords.set(collectionName, /* @__PURE__ */ new Map());
221
+ }
222
+ }
223
+ this.backfillAllCollections();
224
+ }
71
225
  async applyRemoteOperation(op) {
72
226
  this.assertOpen();
73
227
  if (this.operationIndex.has(op.id)) {
@@ -79,6 +233,9 @@ var MemoryServerStore = class {
79
233
  if (op.sequenceNumber > currentSeq) {
80
234
  this.versionVector.set(op.nodeId, op.sequenceNumber);
81
235
  }
236
+ if (this.schema && this.schema.collections[op.collection]) {
237
+ this.rebuildMaterializedRecord(op.collection, op.recordId);
238
+ }
82
239
  return "applied";
83
240
  }
84
241
  async getOperationRange(nodeId, fromSeq, toSeq) {
@@ -91,6 +248,114 @@ var MemoryServerStore = class {
91
248
  this.assertOpen();
92
249
  return this.operations.length;
93
250
  }
251
+ async materializeCollection(collection) {
252
+ this.assertOpen();
253
+ if (this.schema && this.schema.collections[collection]) {
254
+ return this.queryCollection(collection);
255
+ }
256
+ return this.materializeFromOps(collection);
257
+ }
258
+ async queryCollection(collection, options) {
259
+ this.assertOpen();
260
+ this.assertSchema();
261
+ this.assertCollection(collection);
262
+ if (options?.where) {
263
+ for (const key of Object.keys(options.where)) {
264
+ validateFieldName(collection, key, this.schema);
265
+ }
266
+ }
267
+ if (options?.orderBy) {
268
+ validateFieldName(collection, options.orderBy, this.schema);
269
+ }
270
+ const collectionMap = this.materializedRecords.get(collection);
271
+ if (!collectionMap) return [];
272
+ let records = Array.from(collectionMap.values()).filter((r) => {
273
+ if (!options?.includeDeleted && r._deleted === 1) return false;
274
+ return true;
275
+ });
276
+ if (options?.where) {
277
+ for (const [key, value] of Object.entries(options.where)) {
278
+ records = records.filter((r) => r[key] === value);
279
+ }
280
+ }
281
+ if (options?.orderBy) {
282
+ const field = options.orderBy;
283
+ const dir = options.orderDirection === "desc" ? -1 : 1;
284
+ records.sort((a, b) => {
285
+ const aVal = a[field];
286
+ const bVal = b[field];
287
+ if (aVal === bVal) return 0;
288
+ if (aVal === null || aVal === void 0) return 1;
289
+ if (bVal === null || bVal === void 0) return -1;
290
+ return aVal < bVal ? -1 * dir : 1 * dir;
291
+ });
292
+ }
293
+ if (options?.offset !== void 0) {
294
+ records = records.slice(options.offset);
295
+ }
296
+ if (options?.limit !== void 0) {
297
+ records = records.slice(0, options.limit);
298
+ }
299
+ return records.map((r) => {
300
+ const clean = { id: r.id };
301
+ const collectionDef = this.schema.collections[collection];
302
+ for (const fieldName of Object.keys(collectionDef.fields)) {
303
+ if (fieldName in r) {
304
+ clean[fieldName] = r[fieldName];
305
+ }
306
+ }
307
+ if ("_created_at" in r) clean._created_at = r._created_at;
308
+ if ("_updated_at" in r) clean._updated_at = r._updated_at;
309
+ return clean;
310
+ });
311
+ }
312
+ async findRecord(collection, id) {
313
+ this.assertOpen();
314
+ this.assertSchema();
315
+ this.assertCollection(collection);
316
+ const collectionMap = this.materializedRecords.get(collection);
317
+ if (!collectionMap) return null;
318
+ const record = collectionMap.get(id);
319
+ if (!record || record._deleted === 1) return null;
320
+ const clean = { id: record.id };
321
+ const collectionDef = this.schema.collections[collection];
322
+ for (const fieldName of Object.keys(collectionDef.fields)) {
323
+ if (fieldName in record) {
324
+ clean[fieldName] = record[fieldName];
325
+ }
326
+ }
327
+ if ("_created_at" in record) clean._created_at = record._created_at;
328
+ if ("_updated_at" in record) clean._updated_at = record._updated_at;
329
+ return clean;
330
+ }
331
+ async countCollection(collection, where) {
332
+ this.assertOpen();
333
+ this.assertSchema();
334
+ this.assertCollection(collection);
335
+ if (where) {
336
+ for (const key of Object.keys(where)) {
337
+ validateFieldName(collection, key, this.schema);
338
+ }
339
+ }
340
+ const collectionMap = this.materializedRecords.get(collection);
341
+ if (!collectionMap) return 0;
342
+ let count3 = 0;
343
+ for (const record of collectionMap.values()) {
344
+ if (record._deleted === 1) continue;
345
+ if (where) {
346
+ let matches = true;
347
+ for (const [key, value] of Object.entries(where)) {
348
+ if (record[key] !== value) {
349
+ matches = false;
350
+ break;
351
+ }
352
+ }
353
+ if (!matches) continue;
354
+ }
355
+ count3++;
356
+ }
357
+ return count3;
358
+ }
94
359
  async close() {
95
360
  this.closed = true;
96
361
  }
@@ -101,11 +366,124 @@ var MemoryServerStore = class {
101
366
  getAllOperations() {
102
367
  return [...this.operations];
103
368
  }
369
+ // ---------------------------------------------------------------------------
370
+ // Materialization internals
371
+ // ---------------------------------------------------------------------------
372
+ rebuildMaterializedRecord(collection, recordId) {
373
+ const collectionDef = this.schema.collections[collection];
374
+ if (!collectionDef) return;
375
+ let collectionMap = this.materializedRecords.get(collection);
376
+ if (!collectionMap) {
377
+ collectionMap = /* @__PURE__ */ new Map();
378
+ this.materializedRecords.set(collection, collectionMap);
379
+ }
380
+ const recordOps = this.operations.filter((op) => op.collection === collection && op.recordId === recordId).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 parsedOps = recordOps.map((op) => ({
386
+ type: op.type,
387
+ data: op.data
388
+ }));
389
+ const recordData = replayOperationsForRecord(parsedOps);
390
+ if (recordData) {
391
+ const createdAt = recordOps.length > 0 ? recordOps[0].timestamp.wallTime : Date.now();
392
+ const updatedAt = recordOps.length > 0 ? recordOps[recordOps.length - 1].timestamp.wallTime : Date.now();
393
+ const materialized = {
394
+ id: recordId,
395
+ ...recordData,
396
+ _created_at: createdAt,
397
+ _updated_at: updatedAt,
398
+ _deleted: 0
399
+ };
400
+ collectionMap.set(recordId, materialized);
401
+ } else {
402
+ const existing = collectionMap.get(recordId);
403
+ if (existing) {
404
+ existing._deleted = 1;
405
+ existing._updated_at = Date.now();
406
+ } else {
407
+ collectionMap.set(recordId, {
408
+ id: recordId,
409
+ _deleted: 1,
410
+ _created_at: Date.now(),
411
+ _updated_at: Date.now()
412
+ });
413
+ }
414
+ }
415
+ }
416
+ backfillAllCollections() {
417
+ if (!this.schema) return;
418
+ const recordKeys = /* @__PURE__ */ new Set();
419
+ for (const op of this.operations) {
420
+ if (this.schema.collections[op.collection]) {
421
+ recordKeys.add(`${op.collection}:::${op.recordId}`);
422
+ }
423
+ }
424
+ for (const key of recordKeys) {
425
+ const [collection, recordId] = key.split(":::");
426
+ this.rebuildMaterializedRecord(collection, recordId);
427
+ }
428
+ }
429
+ // ---------------------------------------------------------------------------
430
+ // Fallback materialization (no schema)
431
+ // ---------------------------------------------------------------------------
432
+ materializeFromOps(collection) {
433
+ const collectionOps = this.operations.filter((op) => op.collection === collection).sort((a, b) => {
434
+ if (a.timestamp.wallTime !== b.timestamp.wallTime) return a.timestamp.wallTime - b.timestamp.wallTime;
435
+ if (a.timestamp.logical !== b.timestamp.logical) return a.timestamp.logical - b.timestamp.logical;
436
+ return a.sequenceNumber - b.sequenceNumber;
437
+ });
438
+ const records = /* @__PURE__ */ new Map();
439
+ const deleted = /* @__PURE__ */ new Set();
440
+ for (const op of collectionOps) {
441
+ switch (op.type) {
442
+ case "insert":
443
+ if (op.data) {
444
+ records.set(op.recordId, { id: op.recordId, ...op.data });
445
+ deleted.delete(op.recordId);
446
+ }
447
+ break;
448
+ case "update":
449
+ if (op.data) {
450
+ const existing = records.get(op.recordId) ?? { id: op.recordId };
451
+ records.set(op.recordId, { ...existing, ...op.data });
452
+ deleted.delete(op.recordId);
453
+ }
454
+ break;
455
+ case "delete":
456
+ deleted.add(op.recordId);
457
+ break;
458
+ }
459
+ }
460
+ for (const id of deleted) {
461
+ records.delete(id);
462
+ }
463
+ return Array.from(records.values());
464
+ }
465
+ // ---------------------------------------------------------------------------
466
+ // Assertions
467
+ // ---------------------------------------------------------------------------
104
468
  assertOpen() {
105
469
  if (this.closed) {
106
470
  throw new Error("MemoryServerStore is closed");
107
471
  }
108
472
  }
473
+ assertSchema() {
474
+ if (!this.schema) {
475
+ throw new Error(
476
+ "Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
477
+ );
478
+ }
479
+ }
480
+ assertCollection(collection) {
481
+ if (!this.schema.collections[collection]) {
482
+ throw new Error(
483
+ `Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
484
+ );
485
+ }
486
+ }
109
487
  };
110
488
 
111
489
  // src/store/postgres-server-store.ts
@@ -153,6 +531,7 @@ var PostgresServerStore = class {
153
531
  db;
154
532
  versionVector = /* @__PURE__ */ new Map();
155
533
  ready;
534
+ schema = null;
156
535
  closed = false;
157
536
  constructor(db, nodeId) {
158
537
  this.db = db;
@@ -166,6 +545,27 @@ var PostgresServerStore = class {
166
545
  getNodeId() {
167
546
  return this.nodeId;
168
547
  }
548
+ async setSchema(schema) {
549
+ this.assertOpen();
550
+ await this.ready;
551
+ this.schema = schema;
552
+ const ddlStatements = generateAllCollectionDDL(schema, "postgres");
553
+ for (const stmt of ddlStatements) {
554
+ if (stmt.startsWith("--kora:safe-alter")) {
555
+ const alterSql = stmt.replace("--kora:safe-alter\n", "");
556
+ try {
557
+ await this.db.execute(import_drizzle_orm.sql.raw(alterSql));
558
+ } catch (e) {
559
+ if (!(e instanceof Error && (e.message.includes("already exists") || e.message.includes("duplicate column")))) {
560
+ throw e;
561
+ }
562
+ }
563
+ } else {
564
+ await this.db.execute(import_drizzle_orm.sql.raw(stmt));
565
+ }
566
+ }
567
+ await this.backfillAllCollections();
568
+ }
169
569
  async applyRemoteOperation(op) {
170
570
  this.assertOpen();
171
571
  await this.ready;
@@ -188,6 +588,9 @@ var PostgresServerStore = class {
188
588
  lastSeenAt: import_drizzle_orm.sql`${now}`
189
589
  }
190
590
  });
591
+ if (this.schema && this.schema.collections[op.collection]) {
592
+ await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
593
+ }
191
594
  });
192
595
  const currentMax = this.versionVector.get(op.nodeId) ?? 0;
193
596
  if (op.sequenceNumber > currentMax) {
@@ -212,9 +615,291 @@ var PostgresServerStore = class {
212
615
  const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
213
616
  return result[0]?.value ?? 0;
214
617
  }
618
+ async materializeCollection(collection) {
619
+ this.assertOpen();
620
+ await this.ready;
621
+ if (this.schema && this.schema.collections[collection]) {
622
+ return this.queryCollection(collection);
623
+ }
624
+ return this.materializeFromOpsLog(collection);
625
+ }
626
+ async queryCollection(collection, options) {
627
+ this.assertOpen();
628
+ await this.ready;
629
+ this.assertSchema();
630
+ this.assertCollection(collection);
631
+ const collectionDef = this.schema.collections[collection];
632
+ if (options?.where) {
633
+ for (const key of Object.keys(options.where)) {
634
+ validateFieldName(collection, key, this.schema);
635
+ }
636
+ }
637
+ if (options?.orderBy) {
638
+ validateFieldName(collection, options.orderBy, this.schema);
639
+ }
640
+ const query = this.buildSelectQuery(collection, options);
641
+ const rows = await this.db.execute(query);
642
+ return rows.map((row) => this.deserializeRow(row, collectionDef));
643
+ }
644
+ async findRecord(collection, id) {
645
+ this.assertOpen();
646
+ await this.ready;
647
+ this.assertSchema();
648
+ this.assertCollection(collection);
649
+ const collectionDef = this.schema.collections[collection];
650
+ const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
651
+ const rows = await this.db.execute(query);
652
+ if (rows.length === 0) return null;
653
+ return this.deserializeRow(rows[0], collectionDef);
654
+ }
655
+ async countCollection(collection, where) {
656
+ this.assertOpen();
657
+ await this.ready;
658
+ this.assertSchema();
659
+ this.assertCollection(collection);
660
+ if (where) {
661
+ for (const key of Object.keys(where)) {
662
+ validateFieldName(collection, key, this.schema);
663
+ }
664
+ }
665
+ const whereClause = this.buildWhereClause(where ?? {}, false);
666
+ const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
667
+ const rows = await this.db.execute(query);
668
+ const cnt = rows[0]?.cnt;
669
+ return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
670
+ }
215
671
  async close() {
216
672
  this.closed = true;
217
673
  }
674
+ // ---------------------------------------------------------------------------
675
+ // Materialization internals
676
+ // ---------------------------------------------------------------------------
677
+ /**
678
+ * Rebuild a single record in the materialized collection table by replaying
679
+ * all operations for that record.
680
+ */
681
+ async rebuildMaterializedRecord(txOrDb, collection, recordId) {
682
+ const collectionDef = this.schema.collections[collection];
683
+ if (!collectionDef) return;
684
+ const ops = await txOrDb.select({
685
+ type: pgOperations.type,
686
+ data: pgOperations.data,
687
+ wallTime: pgOperations.wallTime
688
+ }).from(pgOperations).where(
689
+ (0, import_drizzle_orm.and)(
690
+ (0, import_drizzle_orm.eq)(pgOperations.collection, collection),
691
+ (0, import_drizzle_orm.eq)(pgOperations.recordId, recordId)
692
+ )
693
+ ).orderBy(
694
+ (0, import_drizzle_orm.asc)(pgOperations.wallTime),
695
+ (0, import_drizzle_orm.asc)(pgOperations.logical),
696
+ (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
697
+ );
698
+ const parsedOps = ops.map((op) => ({
699
+ type: op.type,
700
+ data: op.data !== null ? JSON.parse(op.data) : null
701
+ }));
702
+ const recordData = replayOperationsForRecord(parsedOps);
703
+ const fieldNames = Object.keys(collectionDef.fields);
704
+ if (recordData) {
705
+ const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
706
+ const updatedAt = ops.length > 0 ? ops[ops.length - 1].wallTime : Date.now();
707
+ await this.upsertMaterializedRecord(
708
+ txOrDb,
709
+ collection,
710
+ recordId,
711
+ recordData,
712
+ fieldNames,
713
+ collectionDef,
714
+ createdAt,
715
+ updatedAt
716
+ );
717
+ } else {
718
+ await txOrDb.execute(
719
+ import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
720
+ );
721
+ }
722
+ }
723
+ /**
724
+ * UPSERT a record into the materialized collection table.
725
+ */
726
+ async upsertMaterializedRecord(txOrDb, tableName, recordId, recordData, fieldNames, collectionDef, createdAt, updatedAt) {
727
+ const allColumns = ["id", ...fieldNames, "_created_at", "_updated_at", "_deleted"];
728
+ const values = [
729
+ recordId,
730
+ ...fieldNames.map((f) => {
731
+ const descriptor = collectionDef.fields[f];
732
+ return descriptor ? serializeFieldValue(recordData[f] ?? null, descriptor) : null;
733
+ }),
734
+ createdAt,
735
+ updatedAt,
736
+ 0
737
+ ];
738
+ const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
739
+ const valuesSql = import_drizzle_orm.sql.join(
740
+ values.map((v) => import_drizzle_orm.sql`${v}`),
741
+ import_drizzle_orm.sql.raw(", ")
742
+ );
743
+ const updateSet = import_drizzle_orm.sql.raw(
744
+ allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
745
+ );
746
+ await txOrDb.execute(
747
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
748
+ );
749
+ }
750
+ /**
751
+ * Backfill all materialized collection tables from the existing operation log.
752
+ */
753
+ async backfillAllCollections() {
754
+ if (!this.schema) return;
755
+ for (const collectionName of Object.keys(this.schema.collections)) {
756
+ await this.backfillCollection(collectionName);
757
+ }
758
+ }
759
+ /**
760
+ * Backfill a single collection's materialized table from operations.
761
+ */
762
+ async backfillCollection(collectionName) {
763
+ const collectionDef = this.schema.collections[collectionName];
764
+ if (!collectionDef) return;
765
+ const allOps = await this.db.select({
766
+ recordId: pgOperations.recordId,
767
+ type: pgOperations.type,
768
+ data: pgOperations.data,
769
+ wallTime: pgOperations.wallTime
770
+ }).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
771
+ (0, import_drizzle_orm.asc)(pgOperations.wallTime),
772
+ (0, import_drizzle_orm.asc)(pgOperations.logical),
773
+ (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
774
+ );
775
+ if (allOps.length === 0) return;
776
+ const grouped = /* @__PURE__ */ new Map();
777
+ for (const op of allOps) {
778
+ let group = grouped.get(op.recordId);
779
+ if (!group) {
780
+ group = [];
781
+ grouped.set(op.recordId, group);
782
+ }
783
+ group.push(op);
784
+ }
785
+ const fieldNames = Object.keys(collectionDef.fields);
786
+ for (const [recordId, recordOps] of grouped) {
787
+ const parsedOps = recordOps.map((op) => ({
788
+ type: op.type,
789
+ data: op.data !== null ? JSON.parse(op.data) : null
790
+ }));
791
+ const recordData = replayOperationsForRecord(parsedOps);
792
+ if (recordData) {
793
+ const createdAt = recordOps[0].wallTime;
794
+ const updatedAt = recordOps[recordOps.length - 1].wallTime;
795
+ await this.upsertMaterializedRecord(
796
+ this.db,
797
+ collectionName,
798
+ recordId,
799
+ recordData,
800
+ fieldNames,
801
+ collectionDef,
802
+ createdAt,
803
+ updatedAt
804
+ );
805
+ } else {
806
+ await this.db.execute(
807
+ import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.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()}`
808
+ );
809
+ }
810
+ }
811
+ }
812
+ // ---------------------------------------------------------------------------
813
+ // Query building
814
+ // ---------------------------------------------------------------------------
815
+ buildSelectQuery(collection, options) {
816
+ const whereClause = this.buildWhereClause(
817
+ options?.where ?? {},
818
+ options?.includeDeleted ?? false
819
+ );
820
+ const parts = [
821
+ import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`
822
+ ];
823
+ if (options?.orderBy) {
824
+ const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
825
+ parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
826
+ }
827
+ if (options?.limit !== void 0) {
828
+ parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
829
+ }
830
+ if (options?.offset !== void 0) {
831
+ parts.push(import_drizzle_orm.sql` OFFSET ${options.offset}`);
832
+ }
833
+ return import_drizzle_orm.sql.join(parts, import_drizzle_orm.sql.raw(""));
834
+ }
835
+ buildWhereClause(where, includeDeleted) {
836
+ const conditions = [];
837
+ if (!includeDeleted) {
838
+ conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
839
+ }
840
+ for (const [key, value] of Object.entries(where)) {
841
+ conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
842
+ }
843
+ if (conditions.length === 0) {
844
+ return import_drizzle_orm.sql.raw("1 = 1");
845
+ }
846
+ return import_drizzle_orm.sql.join(conditions, import_drizzle_orm.sql.raw(" AND "));
847
+ }
848
+ // ---------------------------------------------------------------------------
849
+ // Row deserialization
850
+ // ---------------------------------------------------------------------------
851
+ deserializeRow(row, collectionDef) {
852
+ const record = { id: row.id };
853
+ for (const [fieldName, descriptor] of Object.entries(collectionDef.fields)) {
854
+ if (fieldName in row) {
855
+ record[fieldName] = deserializeFieldValue(row[fieldName], descriptor);
856
+ }
857
+ }
858
+ if ("_created_at" in row) record._created_at = row._created_at;
859
+ if ("_updated_at" in row) record._updated_at = row._updated_at;
860
+ return record;
861
+ }
862
+ // ---------------------------------------------------------------------------
863
+ // Fallback materialization (operation replay, no schema)
864
+ // ---------------------------------------------------------------------------
865
+ async materializeFromOpsLog(collection) {
866
+ const rows = await this.db.select().from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collection)).orderBy(
867
+ (0, import_drizzle_orm.asc)(pgOperations.wallTime),
868
+ (0, import_drizzle_orm.asc)(pgOperations.logical),
869
+ (0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
870
+ );
871
+ const records = /* @__PURE__ */ new Map();
872
+ const deleted = /* @__PURE__ */ new Set();
873
+ for (const row of rows) {
874
+ const recordId = row.recordId;
875
+ const data = row.data !== null ? JSON.parse(row.data) : null;
876
+ switch (row.type) {
877
+ case "insert":
878
+ if (data) {
879
+ records.set(recordId, { id: recordId, ...data });
880
+ deleted.delete(recordId);
881
+ }
882
+ break;
883
+ case "update":
884
+ if (data) {
885
+ const existing = records.get(recordId) ?? { id: recordId };
886
+ records.set(recordId, { ...existing, ...data });
887
+ deleted.delete(recordId);
888
+ }
889
+ break;
890
+ case "delete":
891
+ deleted.add(recordId);
892
+ break;
893
+ }
894
+ }
895
+ for (const id of deleted) {
896
+ records.delete(id);
897
+ }
898
+ return Array.from(records.values());
899
+ }
900
+ // ---------------------------------------------------------------------------
901
+ // Initialization
902
+ // ---------------------------------------------------------------------------
218
903
  async initialize() {
219
904
  await this.ensureTables();
220
905
  const rows = await this.db.select({
@@ -225,10 +910,6 @@ var PostgresServerStore = class {
225
910
  this.versionVector.set(row.nodeId, row.maxSequenceNumber);
226
911
  }
227
912
  }
228
- /**
229
- * Create tables if they don't exist.
230
- * Uses raw SQL via Drizzle's sql template — standard DDL practice without drizzle-kit.
231
- */
232
913
  async ensureTables() {
233
914
  await this.db.execute(import_drizzle_orm.sql`
234
915
  CREATE TABLE IF NOT EXISTS operations (
@@ -257,6 +938,9 @@ var PostgresServerStore = class {
257
938
  await this.db.execute(
258
939
  import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`
259
940
  );
941
+ await this.db.execute(
942
+ import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
943
+ );
260
944
  await this.db.execute(import_drizzle_orm.sql`
261
945
  CREATE TABLE IF NOT EXISTS sync_state (
262
946
  node_id TEXT PRIMARY KEY,
@@ -265,6 +949,9 @@ var PostgresServerStore = class {
265
949
  )
266
950
  `);
267
951
  }
952
+ // ---------------------------------------------------------------------------
953
+ // Operation serialization
954
+ // ---------------------------------------------------------------------------
268
955
  serializeOperation(op, receivedAt) {
269
956
  return {
270
957
  id: op.id,
@@ -302,11 +989,28 @@ var PostgresServerStore = class {
302
989
  schemaVersion: row.schemaVersion
303
990
  };
304
991
  }
992
+ // ---------------------------------------------------------------------------
993
+ // Assertions
994
+ // ---------------------------------------------------------------------------
305
995
  assertOpen() {
306
996
  if (this.closed) {
307
997
  throw new Error("PostgresServerStore is closed");
308
998
  }
309
999
  }
1000
+ assertSchema() {
1001
+ if (!this.schema) {
1002
+ throw new Error(
1003
+ "Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
1004
+ );
1005
+ }
1006
+ }
1007
+ assertCollection(collection) {
1008
+ if (!this.schema.collections[collection]) {
1009
+ throw new Error(
1010
+ `Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
1011
+ );
1012
+ }
1013
+ }
310
1014
  };
311
1015
  async function createPostgresServerStore(options) {
312
1016
  const { postgresClient, drizzleFn } = await loadPostgresDeps();
@@ -375,6 +1079,7 @@ var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
375
1079
  var SqliteServerStore = class {
376
1080
  nodeId;
377
1081
  db;
1082
+ schema = null;
378
1083
  closed = false;
379
1084
  constructor(db, nodeId) {
380
1085
  this.db = db;
@@ -393,6 +1098,28 @@ var SqliteServerStore = class {
393
1098
  getNodeId() {
394
1099
  return this.nodeId;
395
1100
  }
1101
+ async setSchema(schema) {
1102
+ this.assertOpen();
1103
+ this.schema = schema;
1104
+ const ddlStatements = generateAllCollectionDDL(schema, "sqlite");
1105
+ for (const stmt of ddlStatements) {
1106
+ if (stmt.startsWith("--kora:safe-alter")) {
1107
+ const alterSql = stmt.replace("--kora:safe-alter\n", "");
1108
+ try {
1109
+ this.db.run(import_drizzle_orm2.sql.raw(alterSql));
1110
+ } catch (e) {
1111
+ const msg = e instanceof Error ? e.message : "";
1112
+ const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
1113
+ if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
1114
+ throw e;
1115
+ }
1116
+ }
1117
+ } else {
1118
+ this.db.run(import_drizzle_orm2.sql.raw(stmt));
1119
+ }
1120
+ }
1121
+ await this.backfillAllCollections();
1122
+ }
396
1123
  async applyRemoteOperation(op) {
397
1124
  this.assertOpen();
398
1125
  const now = Date.now();
@@ -413,6 +1140,9 @@ var SqliteServerStore = class {
413
1140
  lastSeenAt: import_drizzle_orm2.sql`${now}`
414
1141
  }
415
1142
  }).run();
1143
+ if (this.schema && this.schema.collections[op.collection]) {
1144
+ this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
1145
+ }
416
1146
  return "applied";
417
1147
  });
418
1148
  return result;
@@ -427,12 +1157,282 @@ var SqliteServerStore = class {
427
1157
  const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
428
1158
  return result[0]?.value ?? 0;
429
1159
  }
1160
+ async materializeCollection(collection) {
1161
+ this.assertOpen();
1162
+ if (this.schema && this.schema.collections[collection]) {
1163
+ return this.queryCollection(collection);
1164
+ }
1165
+ return this.materializeFromOpsLog(collection);
1166
+ }
1167
+ async queryCollection(collection, options) {
1168
+ this.assertOpen();
1169
+ this.assertSchema();
1170
+ this.assertCollection(collection);
1171
+ const collectionDef = this.schema.collections[collection];
1172
+ if (options?.where) {
1173
+ for (const key of Object.keys(options.where)) {
1174
+ validateFieldName(collection, key, this.schema);
1175
+ }
1176
+ }
1177
+ if (options?.orderBy) {
1178
+ validateFieldName(collection, options.orderBy, this.schema);
1179
+ }
1180
+ const query = this.buildSelectQuery(collection, options);
1181
+ const rows = this.db.all(query);
1182
+ return rows.map((row) => this.deserializeRow(row, collectionDef));
1183
+ }
1184
+ async findRecord(collection, id) {
1185
+ this.assertOpen();
1186
+ this.assertSchema();
1187
+ this.assertCollection(collection);
1188
+ const collectionDef = this.schema.collections[collection];
1189
+ const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
1190
+ const rows = this.db.all(query);
1191
+ if (rows.length === 0) return null;
1192
+ return this.deserializeRow(rows[0], collectionDef);
1193
+ }
1194
+ async countCollection(collection, where) {
1195
+ this.assertOpen();
1196
+ this.assertSchema();
1197
+ this.assertCollection(collection);
1198
+ if (where) {
1199
+ for (const key of Object.keys(where)) {
1200
+ validateFieldName(collection, key, this.schema);
1201
+ }
1202
+ }
1203
+ const whereClause = this.buildWhereClause(where ?? {}, false);
1204
+ const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
1205
+ const rows = this.db.all(query);
1206
+ return rows[0]?.cnt ?? 0;
1207
+ }
430
1208
  async close() {
431
1209
  this.closed = true;
432
1210
  }
1211
+ // ---------------------------------------------------------------------------
1212
+ // Materialization internals
1213
+ // ---------------------------------------------------------------------------
1214
+ /**
1215
+ * Rebuild a single record in the materialized collection table by replaying
1216
+ * all operations for that record. Called within the applyRemoteOperation
1217
+ * transaction for atomic dual-write.
1218
+ */
1219
+ rebuildMaterializedRecord(txOrDb, collection, recordId) {
1220
+ const collectionDef = this.schema.collections[collection];
1221
+ if (!collectionDef) return;
1222
+ const ops = txOrDb.select({
1223
+ type: operations.type,
1224
+ data: operations.data,
1225
+ wallTime: operations.wallTime
1226
+ }).from(operations).where(
1227
+ (0, import_drizzle_orm2.and)(
1228
+ (0, import_drizzle_orm2.eq)(operations.collection, collection),
1229
+ (0, import_drizzle_orm2.eq)(operations.recordId, recordId)
1230
+ )
1231
+ ).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1232
+ const parsedOps = ops.map((op) => ({
1233
+ type: op.type,
1234
+ data: op.data !== null ? JSON.parse(op.data) : null
1235
+ }));
1236
+ const recordData = replayOperationsForRecord(parsedOps);
1237
+ const fieldNames = Object.keys(collectionDef.fields);
1238
+ if (recordData) {
1239
+ const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
1240
+ const updatedAt = ops.length > 0 ? ops[ops.length - 1].wallTime : Date.now();
1241
+ this.upsertMaterializedRecord(
1242
+ txOrDb,
1243
+ collection,
1244
+ recordId,
1245
+ recordData,
1246
+ fieldNames,
1247
+ collectionDef,
1248
+ createdAt,
1249
+ updatedAt
1250
+ );
1251
+ } else {
1252
+ txOrDb.run(
1253
+ import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
1254
+ );
1255
+ }
1256
+ }
1257
+ /**
1258
+ * UPSERT a record into the materialized collection table.
1259
+ * Uses INSERT ... ON CONFLICT (id) DO UPDATE SET for atomic upsert.
1260
+ */
1261
+ upsertMaterializedRecord(txOrDb, tableName, recordId, recordData, fieldNames, collectionDef, createdAt, updatedAt) {
1262
+ const allColumns = ["id", ...fieldNames, "_created_at", "_updated_at", "_deleted"];
1263
+ const values = [
1264
+ recordId,
1265
+ ...fieldNames.map((f) => {
1266
+ const descriptor = collectionDef.fields[f];
1267
+ return descriptor ? serializeFieldValue(recordData[f] ?? null, descriptor) : null;
1268
+ }),
1269
+ createdAt,
1270
+ updatedAt,
1271
+ 0
1272
+ // _deleted = false
1273
+ ];
1274
+ const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
1275
+ const valuesSql = import_drizzle_orm2.sql.join(
1276
+ values.map((v) => import_drizzle_orm2.sql`${v}`),
1277
+ import_drizzle_orm2.sql.raw(", ")
1278
+ );
1279
+ const updateSet = import_drizzle_orm2.sql.raw(
1280
+ allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
1281
+ );
1282
+ txOrDb.run(
1283
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
1284
+ );
1285
+ }
1286
+ /**
1287
+ * Backfill all materialized collection tables from the existing operation log.
1288
+ * Called when setSchema() is invoked and operations already exist.
1289
+ */
1290
+ async backfillAllCollections() {
1291
+ if (!this.schema) return;
1292
+ for (const collectionName of Object.keys(this.schema.collections)) {
1293
+ this.backfillCollection(collectionName);
1294
+ }
1295
+ }
1296
+ /**
1297
+ * Backfill a single collection's materialized table from operations.
1298
+ */
1299
+ backfillCollection(collectionName) {
1300
+ const collectionDef = this.schema.collections[collectionName];
1301
+ if (!collectionDef) return;
1302
+ const allOps = this.db.select({
1303
+ recordId: operations.recordId,
1304
+ type: operations.type,
1305
+ data: operations.data,
1306
+ wallTime: operations.wallTime
1307
+ }).from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collectionName)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1308
+ if (allOps.length === 0) return;
1309
+ const grouped = /* @__PURE__ */ new Map();
1310
+ for (const op of allOps) {
1311
+ let group = grouped.get(op.recordId);
1312
+ if (!group) {
1313
+ group = [];
1314
+ grouped.set(op.recordId, group);
1315
+ }
1316
+ group.push(op);
1317
+ }
1318
+ const fieldNames = Object.keys(collectionDef.fields);
1319
+ this.db.transaction((tx) => {
1320
+ for (const [recordId, recordOps] of grouped) {
1321
+ const parsedOps = recordOps.map((op) => ({
1322
+ type: op.type,
1323
+ data: op.data !== null ? JSON.parse(op.data) : null
1324
+ }));
1325
+ const recordData = replayOperationsForRecord(parsedOps);
1326
+ if (recordData) {
1327
+ const createdAt = recordOps[0].wallTime;
1328
+ const updatedAt = recordOps[recordOps.length - 1].wallTime;
1329
+ this.upsertMaterializedRecord(
1330
+ tx,
1331
+ collectionName,
1332
+ recordId,
1333
+ recordData,
1334
+ fieldNames,
1335
+ collectionDef,
1336
+ createdAt,
1337
+ updatedAt
1338
+ );
1339
+ } else {
1340
+ tx.run(
1341
+ import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.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()}`
1342
+ );
1343
+ }
1344
+ }
1345
+ });
1346
+ }
1347
+ // ---------------------------------------------------------------------------
1348
+ // Query building
1349
+ // ---------------------------------------------------------------------------
1350
+ buildSelectQuery(collection, options) {
1351
+ const whereClause = this.buildWhereClause(
1352
+ options?.where ?? {},
1353
+ options?.includeDeleted ?? false
1354
+ );
1355
+ const parts = [
1356
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`
1357
+ ];
1358
+ if (options?.orderBy) {
1359
+ const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
1360
+ parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
1361
+ }
1362
+ if (options?.limit !== void 0) {
1363
+ parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
1364
+ }
1365
+ if (options?.offset !== void 0) {
1366
+ parts.push(import_drizzle_orm2.sql` OFFSET ${options.offset}`);
1367
+ }
1368
+ return import_drizzle_orm2.sql.join(parts, import_drizzle_orm2.sql.raw(""));
1369
+ }
1370
+ buildWhereClause(where, includeDeleted) {
1371
+ const conditions = [];
1372
+ if (!includeDeleted) {
1373
+ conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
1374
+ }
1375
+ for (const [key, value] of Object.entries(where)) {
1376
+ conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
1377
+ }
1378
+ if (conditions.length === 0) {
1379
+ return import_drizzle_orm2.sql.raw("1 = 1");
1380
+ }
1381
+ return import_drizzle_orm2.sql.join(conditions, import_drizzle_orm2.sql.raw(" AND "));
1382
+ }
1383
+ // ---------------------------------------------------------------------------
1384
+ // Row deserialization
1385
+ // ---------------------------------------------------------------------------
1386
+ deserializeRow(row, collectionDef) {
1387
+ const record = { id: row.id };
1388
+ for (const [fieldName, descriptor] of Object.entries(collectionDef.fields)) {
1389
+ if (fieldName in row) {
1390
+ record[fieldName] = deserializeFieldValue(row[fieldName], descriptor);
1391
+ }
1392
+ }
1393
+ if ("_created_at" in row) record._created_at = row._created_at;
1394
+ if ("_updated_at" in row) record._updated_at = row._updated_at;
1395
+ return record;
1396
+ }
1397
+ // ---------------------------------------------------------------------------
1398
+ // Fallback materialization (operation replay, no schema)
1399
+ // ---------------------------------------------------------------------------
1400
+ materializeFromOpsLog(collection) {
1401
+ const rows = this.db.select().from(operations).where((0, import_drizzle_orm2.eq)(operations.collection, collection)).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
1402
+ const records = /* @__PURE__ */ new Map();
1403
+ const deleted = /* @__PURE__ */ new Set();
1404
+ for (const row of rows) {
1405
+ const recordId = row.recordId;
1406
+ const data = row.data !== null ? JSON.parse(row.data) : null;
1407
+ switch (row.type) {
1408
+ case "insert":
1409
+ if (data) {
1410
+ records.set(recordId, { id: recordId, ...data });
1411
+ deleted.delete(recordId);
1412
+ }
1413
+ break;
1414
+ case "update":
1415
+ if (data) {
1416
+ const existing = records.get(recordId) ?? { id: recordId };
1417
+ records.set(recordId, { ...existing, ...data });
1418
+ deleted.delete(recordId);
1419
+ }
1420
+ break;
1421
+ case "delete":
1422
+ deleted.add(recordId);
1423
+ break;
1424
+ }
1425
+ }
1426
+ for (const id of deleted) {
1427
+ records.delete(id);
1428
+ }
1429
+ return Array.from(records.values());
1430
+ }
1431
+ // ---------------------------------------------------------------------------
1432
+ // Table setup
1433
+ // ---------------------------------------------------------------------------
433
1434
  /**
434
1435
  * Create the operations and sync_state tables if they don't exist.
435
- * Uses raw SQL via Drizzle's sql template — standard practice for DDL without drizzle-kit.
436
1436
  */
437
1437
  ensureTables() {
438
1438
  this.db.run(import_drizzle_orm2.sql`
@@ -462,6 +1462,9 @@ var SqliteServerStore = class {
462
1462
  this.db.run(import_drizzle_orm2.sql`
463
1463
  CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)
464
1464
  `);
1465
+ this.db.run(import_drizzle_orm2.sql`
1466
+ CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)
1467
+ `);
465
1468
  this.db.run(import_drizzle_orm2.sql`
466
1469
  CREATE TABLE IF NOT EXISTS sync_state (
467
1470
  node_id TEXT PRIMARY KEY,
@@ -470,6 +1473,9 @@ var SqliteServerStore = class {
470
1473
  )
471
1474
  `);
472
1475
  }
1476
+ // ---------------------------------------------------------------------------
1477
+ // Operation serialization
1478
+ // ---------------------------------------------------------------------------
473
1479
  serializeOperation(op, receivedAt) {
474
1480
  return {
475
1481
  id: op.id,
@@ -507,11 +1513,28 @@ var SqliteServerStore = class {
507
1513
  schemaVersion: row.schemaVersion
508
1514
  };
509
1515
  }
1516
+ // ---------------------------------------------------------------------------
1517
+ // Assertions
1518
+ // ---------------------------------------------------------------------------
510
1519
  assertOpen() {
511
1520
  if (this.closed) {
512
1521
  throw new Error("SqliteServerStore is closed");
513
1522
  }
514
1523
  }
1524
+ assertSchema() {
1525
+ if (!this.schema) {
1526
+ throw new Error(
1527
+ "Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
1528
+ );
1529
+ }
1530
+ }
1531
+ assertCollection(collection) {
1532
+ if (!this.schema.collections[collection]) {
1533
+ throw new Error(
1534
+ `Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
1535
+ );
1536
+ }
1537
+ }
515
1538
  };
516
1539
  function createSqliteServerStore(options) {
517
1540
  const Database = esmRequire("better-sqlite3");