@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.cjs +1030 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +161 -13
- package/dist/index.d.ts +161 -13
- package/dist/index.js +1030 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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,29 @@ 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
|
+
const msg = e instanceof Error ? e.message : "";
|
|
560
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
561
|
+
if (!msg.includes("already exists") && !msg.includes("duplicate column") && !causeMsg.includes("already exists") && !causeMsg.includes("duplicate column")) {
|
|
562
|
+
throw e;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
await this.db.execute(import_drizzle_orm.sql.raw(stmt));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
await this.backfillAllCollections();
|
|
570
|
+
}
|
|
169
571
|
async applyRemoteOperation(op) {
|
|
170
572
|
this.assertOpen();
|
|
171
573
|
await this.ready;
|
|
@@ -188,6 +590,9 @@ var PostgresServerStore = class {
|
|
|
188
590
|
lastSeenAt: import_drizzle_orm.sql`${now}`
|
|
189
591
|
}
|
|
190
592
|
});
|
|
593
|
+
if (this.schema && this.schema.collections[op.collection]) {
|
|
594
|
+
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
595
|
+
}
|
|
191
596
|
});
|
|
192
597
|
const currentMax = this.versionVector.get(op.nodeId) ?? 0;
|
|
193
598
|
if (op.sequenceNumber > currentMax) {
|
|
@@ -212,9 +617,291 @@ var PostgresServerStore = class {
|
|
|
212
617
|
const result = await this.db.select({ value: (0, import_drizzle_orm.count)() }).from(pgOperations);
|
|
213
618
|
return result[0]?.value ?? 0;
|
|
214
619
|
}
|
|
620
|
+
async materializeCollection(collection) {
|
|
621
|
+
this.assertOpen();
|
|
622
|
+
await this.ready;
|
|
623
|
+
if (this.schema && this.schema.collections[collection]) {
|
|
624
|
+
return this.queryCollection(collection);
|
|
625
|
+
}
|
|
626
|
+
return this.materializeFromOpsLog(collection);
|
|
627
|
+
}
|
|
628
|
+
async queryCollection(collection, options) {
|
|
629
|
+
this.assertOpen();
|
|
630
|
+
await this.ready;
|
|
631
|
+
this.assertSchema();
|
|
632
|
+
this.assertCollection(collection);
|
|
633
|
+
const collectionDef = this.schema.collections[collection];
|
|
634
|
+
if (options?.where) {
|
|
635
|
+
for (const key of Object.keys(options.where)) {
|
|
636
|
+
validateFieldName(collection, key, this.schema);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (options?.orderBy) {
|
|
640
|
+
validateFieldName(collection, options.orderBy, this.schema);
|
|
641
|
+
}
|
|
642
|
+
const query = this.buildSelectQuery(collection, options);
|
|
643
|
+
const rows = await this.db.execute(query);
|
|
644
|
+
return rows.map((row) => this.deserializeRow(row, collectionDef));
|
|
645
|
+
}
|
|
646
|
+
async findRecord(collection, id) {
|
|
647
|
+
this.assertOpen();
|
|
648
|
+
await this.ready;
|
|
649
|
+
this.assertSchema();
|
|
650
|
+
this.assertCollection(collection);
|
|
651
|
+
const collectionDef = this.schema.collections[collection];
|
|
652
|
+
const query = import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
653
|
+
const rows = await this.db.execute(query);
|
|
654
|
+
if (rows.length === 0) return null;
|
|
655
|
+
return this.deserializeRow(rows[0], collectionDef);
|
|
656
|
+
}
|
|
657
|
+
async countCollection(collection, where) {
|
|
658
|
+
this.assertOpen();
|
|
659
|
+
await this.ready;
|
|
660
|
+
this.assertSchema();
|
|
661
|
+
this.assertCollection(collection);
|
|
662
|
+
if (where) {
|
|
663
|
+
for (const key of Object.keys(where)) {
|
|
664
|
+
validateFieldName(collection, key, this.schema);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
668
|
+
const query = import_drizzle_orm.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`;
|
|
669
|
+
const rows = await this.db.execute(query);
|
|
670
|
+
const cnt = rows[0]?.cnt;
|
|
671
|
+
return typeof cnt === "string" ? Number.parseInt(cnt, 10) : cnt ?? 0;
|
|
672
|
+
}
|
|
215
673
|
async close() {
|
|
216
674
|
this.closed = true;
|
|
217
675
|
}
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// Materialization internals
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
/**
|
|
680
|
+
* Rebuild a single record in the materialized collection table by replaying
|
|
681
|
+
* all operations for that record.
|
|
682
|
+
*/
|
|
683
|
+
async rebuildMaterializedRecord(txOrDb, collection, recordId) {
|
|
684
|
+
const collectionDef = this.schema.collections[collection];
|
|
685
|
+
if (!collectionDef) return;
|
|
686
|
+
const ops = await txOrDb.select({
|
|
687
|
+
type: pgOperations.type,
|
|
688
|
+
data: pgOperations.data,
|
|
689
|
+
wallTime: pgOperations.wallTime
|
|
690
|
+
}).from(pgOperations).where(
|
|
691
|
+
(0, import_drizzle_orm.and)(
|
|
692
|
+
(0, import_drizzle_orm.eq)(pgOperations.collection, collection),
|
|
693
|
+
(0, import_drizzle_orm.eq)(pgOperations.recordId, recordId)
|
|
694
|
+
)
|
|
695
|
+
).orderBy(
|
|
696
|
+
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
697
|
+
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
698
|
+
(0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
|
|
699
|
+
);
|
|
700
|
+
const parsedOps = ops.map((op) => ({
|
|
701
|
+
type: op.type,
|
|
702
|
+
data: op.data !== null ? JSON.parse(op.data) : null
|
|
703
|
+
}));
|
|
704
|
+
const recordData = replayOperationsForRecord(parsedOps);
|
|
705
|
+
const fieldNames = Object.keys(collectionDef.fields);
|
|
706
|
+
if (recordData) {
|
|
707
|
+
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
708
|
+
const updatedAt = ops.length > 0 ? ops[ops.length - 1].wallTime : Date.now();
|
|
709
|
+
await this.upsertMaterializedRecord(
|
|
710
|
+
txOrDb,
|
|
711
|
+
collection,
|
|
712
|
+
recordId,
|
|
713
|
+
recordData,
|
|
714
|
+
fieldNames,
|
|
715
|
+
collectionDef,
|
|
716
|
+
createdAt,
|
|
717
|
+
updatedAt
|
|
718
|
+
);
|
|
719
|
+
} else {
|
|
720
|
+
await txOrDb.execute(
|
|
721
|
+
import_drizzle_orm.sql`UPDATE ${import_drizzle_orm.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* UPSERT a record into the materialized collection table.
|
|
727
|
+
*/
|
|
728
|
+
async upsertMaterializedRecord(txOrDb, tableName, recordId, recordData, fieldNames, collectionDef, createdAt, updatedAt) {
|
|
729
|
+
const allColumns = ["id", ...fieldNames, "_created_at", "_updated_at", "_deleted"];
|
|
730
|
+
const values = [
|
|
731
|
+
recordId,
|
|
732
|
+
...fieldNames.map((f) => {
|
|
733
|
+
const descriptor = collectionDef.fields[f];
|
|
734
|
+
return descriptor ? serializeFieldValue(recordData[f] ?? null, descriptor) : null;
|
|
735
|
+
}),
|
|
736
|
+
createdAt,
|
|
737
|
+
updatedAt,
|
|
738
|
+
0
|
|
739
|
+
];
|
|
740
|
+
const columnsSql = import_drizzle_orm.sql.raw(allColumns.join(", "));
|
|
741
|
+
const valuesSql = import_drizzle_orm.sql.join(
|
|
742
|
+
values.map((v) => import_drizzle_orm.sql`${v}`),
|
|
743
|
+
import_drizzle_orm.sql.raw(", ")
|
|
744
|
+
);
|
|
745
|
+
const updateSet = import_drizzle_orm.sql.raw(
|
|
746
|
+
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
747
|
+
);
|
|
748
|
+
await txOrDb.execute(
|
|
749
|
+
import_drizzle_orm.sql`INSERT INTO ${import_drizzle_orm.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Backfill all materialized collection tables from the existing operation log.
|
|
754
|
+
*/
|
|
755
|
+
async backfillAllCollections() {
|
|
756
|
+
if (!this.schema) return;
|
|
757
|
+
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
758
|
+
await this.backfillCollection(collectionName);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Backfill a single collection's materialized table from operations.
|
|
763
|
+
*/
|
|
764
|
+
async backfillCollection(collectionName) {
|
|
765
|
+
const collectionDef = this.schema.collections[collectionName];
|
|
766
|
+
if (!collectionDef) return;
|
|
767
|
+
const allOps = await this.db.select({
|
|
768
|
+
recordId: pgOperations.recordId,
|
|
769
|
+
type: pgOperations.type,
|
|
770
|
+
data: pgOperations.data,
|
|
771
|
+
wallTime: pgOperations.wallTime
|
|
772
|
+
}).from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collectionName)).orderBy(
|
|
773
|
+
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
774
|
+
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
775
|
+
(0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
|
|
776
|
+
);
|
|
777
|
+
if (allOps.length === 0) return;
|
|
778
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
779
|
+
for (const op of allOps) {
|
|
780
|
+
let group = grouped.get(op.recordId);
|
|
781
|
+
if (!group) {
|
|
782
|
+
group = [];
|
|
783
|
+
grouped.set(op.recordId, group);
|
|
784
|
+
}
|
|
785
|
+
group.push(op);
|
|
786
|
+
}
|
|
787
|
+
const fieldNames = Object.keys(collectionDef.fields);
|
|
788
|
+
for (const [recordId, recordOps] of grouped) {
|
|
789
|
+
const parsedOps = recordOps.map((op) => ({
|
|
790
|
+
type: op.type,
|
|
791
|
+
data: op.data !== null ? JSON.parse(op.data) : null
|
|
792
|
+
}));
|
|
793
|
+
const recordData = replayOperationsForRecord(parsedOps);
|
|
794
|
+
if (recordData) {
|
|
795
|
+
const createdAt = recordOps[0].wallTime;
|
|
796
|
+
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
797
|
+
await this.upsertMaterializedRecord(
|
|
798
|
+
this.db,
|
|
799
|
+
collectionName,
|
|
800
|
+
recordId,
|
|
801
|
+
recordData,
|
|
802
|
+
fieldNames,
|
|
803
|
+
collectionDef,
|
|
804
|
+
createdAt,
|
|
805
|
+
updatedAt
|
|
806
|
+
);
|
|
807
|
+
} else {
|
|
808
|
+
await this.db.execute(
|
|
809
|
+
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()}`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
// Query building
|
|
816
|
+
// ---------------------------------------------------------------------------
|
|
817
|
+
buildSelectQuery(collection, options) {
|
|
818
|
+
const whereClause = this.buildWhereClause(
|
|
819
|
+
options?.where ?? {},
|
|
820
|
+
options?.includeDeleted ?? false
|
|
821
|
+
);
|
|
822
|
+
const parts = [
|
|
823
|
+
import_drizzle_orm.sql`SELECT * FROM ${import_drizzle_orm.sql.raw(collection)} WHERE ${whereClause}`
|
|
824
|
+
];
|
|
825
|
+
if (options?.orderBy) {
|
|
826
|
+
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
827
|
+
parts.push(import_drizzle_orm.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
828
|
+
}
|
|
829
|
+
if (options?.limit !== void 0) {
|
|
830
|
+
parts.push(import_drizzle_orm.sql` LIMIT ${options.limit}`);
|
|
831
|
+
}
|
|
832
|
+
if (options?.offset !== void 0) {
|
|
833
|
+
parts.push(import_drizzle_orm.sql` OFFSET ${options.offset}`);
|
|
834
|
+
}
|
|
835
|
+
return import_drizzle_orm.sql.join(parts, import_drizzle_orm.sql.raw(""));
|
|
836
|
+
}
|
|
837
|
+
buildWhereClause(where, includeDeleted) {
|
|
838
|
+
const conditions = [];
|
|
839
|
+
if (!includeDeleted) {
|
|
840
|
+
conditions.push(import_drizzle_orm.sql.raw("_deleted = 0"));
|
|
841
|
+
}
|
|
842
|
+
for (const [key, value] of Object.entries(where)) {
|
|
843
|
+
conditions.push(import_drizzle_orm.sql`${import_drizzle_orm.sql.raw(key)} = ${value}`);
|
|
844
|
+
}
|
|
845
|
+
if (conditions.length === 0) {
|
|
846
|
+
return import_drizzle_orm.sql.raw("1 = 1");
|
|
847
|
+
}
|
|
848
|
+
return import_drizzle_orm.sql.join(conditions, import_drizzle_orm.sql.raw(" AND "));
|
|
849
|
+
}
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// Row deserialization
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
deserializeRow(row, collectionDef) {
|
|
854
|
+
const record = { id: row.id };
|
|
855
|
+
for (const [fieldName, descriptor] of Object.entries(collectionDef.fields)) {
|
|
856
|
+
if (fieldName in row) {
|
|
857
|
+
record[fieldName] = deserializeFieldValue(row[fieldName], descriptor);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if ("_created_at" in row) record._created_at = row._created_at;
|
|
861
|
+
if ("_updated_at" in row) record._updated_at = row._updated_at;
|
|
862
|
+
return record;
|
|
863
|
+
}
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
// Fallback materialization (operation replay, no schema)
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
async materializeFromOpsLog(collection) {
|
|
868
|
+
const rows = await this.db.select().from(pgOperations).where((0, import_drizzle_orm.eq)(pgOperations.collection, collection)).orderBy(
|
|
869
|
+
(0, import_drizzle_orm.asc)(pgOperations.wallTime),
|
|
870
|
+
(0, import_drizzle_orm.asc)(pgOperations.logical),
|
|
871
|
+
(0, import_drizzle_orm.asc)(pgOperations.sequenceNumber)
|
|
872
|
+
);
|
|
873
|
+
const records = /* @__PURE__ */ new Map();
|
|
874
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
875
|
+
for (const row of rows) {
|
|
876
|
+
const recordId = row.recordId;
|
|
877
|
+
const data = row.data !== null ? JSON.parse(row.data) : null;
|
|
878
|
+
switch (row.type) {
|
|
879
|
+
case "insert":
|
|
880
|
+
if (data) {
|
|
881
|
+
records.set(recordId, { id: recordId, ...data });
|
|
882
|
+
deleted.delete(recordId);
|
|
883
|
+
}
|
|
884
|
+
break;
|
|
885
|
+
case "update":
|
|
886
|
+
if (data) {
|
|
887
|
+
const existing = records.get(recordId) ?? { id: recordId };
|
|
888
|
+
records.set(recordId, { ...existing, ...data });
|
|
889
|
+
deleted.delete(recordId);
|
|
890
|
+
}
|
|
891
|
+
break;
|
|
892
|
+
case "delete":
|
|
893
|
+
deleted.add(recordId);
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
for (const id of deleted) {
|
|
898
|
+
records.delete(id);
|
|
899
|
+
}
|
|
900
|
+
return Array.from(records.values());
|
|
901
|
+
}
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
// Initialization
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
218
905
|
async initialize() {
|
|
219
906
|
await this.ensureTables();
|
|
220
907
|
const rows = await this.db.select({
|
|
@@ -225,10 +912,6 @@ var PostgresServerStore = class {
|
|
|
225
912
|
this.versionVector.set(row.nodeId, row.maxSequenceNumber);
|
|
226
913
|
}
|
|
227
914
|
}
|
|
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
915
|
async ensureTables() {
|
|
233
916
|
await this.db.execute(import_drizzle_orm.sql`
|
|
234
917
|
CREATE TABLE IF NOT EXISTS operations (
|
|
@@ -257,6 +940,9 @@ var PostgresServerStore = class {
|
|
|
257
940
|
await this.db.execute(
|
|
258
941
|
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`
|
|
259
942
|
);
|
|
943
|
+
await this.db.execute(
|
|
944
|
+
import_drizzle_orm.sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
945
|
+
);
|
|
260
946
|
await this.db.execute(import_drizzle_orm.sql`
|
|
261
947
|
CREATE TABLE IF NOT EXISTS sync_state (
|
|
262
948
|
node_id TEXT PRIMARY KEY,
|
|
@@ -265,6 +951,9 @@ var PostgresServerStore = class {
|
|
|
265
951
|
)
|
|
266
952
|
`);
|
|
267
953
|
}
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// Operation serialization
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
268
957
|
serializeOperation(op, receivedAt) {
|
|
269
958
|
return {
|
|
270
959
|
id: op.id,
|
|
@@ -302,11 +991,28 @@ var PostgresServerStore = class {
|
|
|
302
991
|
schemaVersion: row.schemaVersion
|
|
303
992
|
};
|
|
304
993
|
}
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
// Assertions
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
305
997
|
assertOpen() {
|
|
306
998
|
if (this.closed) {
|
|
307
999
|
throw new Error("PostgresServerStore is closed");
|
|
308
1000
|
}
|
|
309
1001
|
}
|
|
1002
|
+
assertSchema() {
|
|
1003
|
+
if (!this.schema) {
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
"Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
assertCollection(collection) {
|
|
1010
|
+
if (!this.schema.collections[collection]) {
|
|
1011
|
+
throw new Error(
|
|
1012
|
+
`Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
310
1016
|
};
|
|
311
1017
|
async function createPostgresServerStore(options) {
|
|
312
1018
|
const { postgresClient, drizzleFn } = await loadPostgresDeps();
|
|
@@ -375,6 +1081,7 @@ var esmRequire = (0, import_node_module.createRequire)(importMetaUrl);
|
|
|
375
1081
|
var SqliteServerStore = class {
|
|
376
1082
|
nodeId;
|
|
377
1083
|
db;
|
|
1084
|
+
schema = null;
|
|
378
1085
|
closed = false;
|
|
379
1086
|
constructor(db, nodeId) {
|
|
380
1087
|
this.db = db;
|
|
@@ -393,6 +1100,28 @@ var SqliteServerStore = class {
|
|
|
393
1100
|
getNodeId() {
|
|
394
1101
|
return this.nodeId;
|
|
395
1102
|
}
|
|
1103
|
+
async setSchema(schema) {
|
|
1104
|
+
this.assertOpen();
|
|
1105
|
+
this.schema = schema;
|
|
1106
|
+
const ddlStatements = generateAllCollectionDDL(schema, "sqlite");
|
|
1107
|
+
for (const stmt of ddlStatements) {
|
|
1108
|
+
if (stmt.startsWith("--kora:safe-alter")) {
|
|
1109
|
+
const alterSql = stmt.replace("--kora:safe-alter\n", "");
|
|
1110
|
+
try {
|
|
1111
|
+
this.db.run(import_drizzle_orm2.sql.raw(alterSql));
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1114
|
+
const causeMsg = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
1115
|
+
if (!msg.includes("duplicate column") && !causeMsg.includes("duplicate column")) {
|
|
1116
|
+
throw e;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} else {
|
|
1120
|
+
this.db.run(import_drizzle_orm2.sql.raw(stmt));
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
await this.backfillAllCollections();
|
|
1124
|
+
}
|
|
396
1125
|
async applyRemoteOperation(op) {
|
|
397
1126
|
this.assertOpen();
|
|
398
1127
|
const now = Date.now();
|
|
@@ -413,6 +1142,9 @@ var SqliteServerStore = class {
|
|
|
413
1142
|
lastSeenAt: import_drizzle_orm2.sql`${now}`
|
|
414
1143
|
}
|
|
415
1144
|
}).run();
|
|
1145
|
+
if (this.schema && this.schema.collections[op.collection]) {
|
|
1146
|
+
this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
1147
|
+
}
|
|
416
1148
|
return "applied";
|
|
417
1149
|
});
|
|
418
1150
|
return result;
|
|
@@ -427,12 +1159,282 @@ var SqliteServerStore = class {
|
|
|
427
1159
|
const result = this.db.select({ value: (0, import_drizzle_orm2.count)() }).from(operations).all();
|
|
428
1160
|
return result[0]?.value ?? 0;
|
|
429
1161
|
}
|
|
1162
|
+
async materializeCollection(collection) {
|
|
1163
|
+
this.assertOpen();
|
|
1164
|
+
if (this.schema && this.schema.collections[collection]) {
|
|
1165
|
+
return this.queryCollection(collection);
|
|
1166
|
+
}
|
|
1167
|
+
return this.materializeFromOpsLog(collection);
|
|
1168
|
+
}
|
|
1169
|
+
async queryCollection(collection, options) {
|
|
1170
|
+
this.assertOpen();
|
|
1171
|
+
this.assertSchema();
|
|
1172
|
+
this.assertCollection(collection);
|
|
1173
|
+
const collectionDef = this.schema.collections[collection];
|
|
1174
|
+
if (options?.where) {
|
|
1175
|
+
for (const key of Object.keys(options.where)) {
|
|
1176
|
+
validateFieldName(collection, key, this.schema);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (options?.orderBy) {
|
|
1180
|
+
validateFieldName(collection, options.orderBy, this.schema);
|
|
1181
|
+
}
|
|
1182
|
+
const query = this.buildSelectQuery(collection, options);
|
|
1183
|
+
const rows = this.db.all(query);
|
|
1184
|
+
return rows.map((row) => this.deserializeRow(row, collectionDef));
|
|
1185
|
+
}
|
|
1186
|
+
async findRecord(collection, id) {
|
|
1187
|
+
this.assertOpen();
|
|
1188
|
+
this.assertSchema();
|
|
1189
|
+
this.assertCollection(collection);
|
|
1190
|
+
const collectionDef = this.schema.collections[collection];
|
|
1191
|
+
const query = import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1192
|
+
const rows = this.db.all(query);
|
|
1193
|
+
if (rows.length === 0) return null;
|
|
1194
|
+
return this.deserializeRow(rows[0], collectionDef);
|
|
1195
|
+
}
|
|
1196
|
+
async countCollection(collection, where) {
|
|
1197
|
+
this.assertOpen();
|
|
1198
|
+
this.assertSchema();
|
|
1199
|
+
this.assertCollection(collection);
|
|
1200
|
+
if (where) {
|
|
1201
|
+
for (const key of Object.keys(where)) {
|
|
1202
|
+
validateFieldName(collection, key, this.schema);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
1206
|
+
const query = import_drizzle_orm2.sql`SELECT COUNT(*) as cnt FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`;
|
|
1207
|
+
const rows = this.db.all(query);
|
|
1208
|
+
return rows[0]?.cnt ?? 0;
|
|
1209
|
+
}
|
|
430
1210
|
async close() {
|
|
431
1211
|
this.closed = true;
|
|
432
1212
|
}
|
|
1213
|
+
// ---------------------------------------------------------------------------
|
|
1214
|
+
// Materialization internals
|
|
1215
|
+
// ---------------------------------------------------------------------------
|
|
1216
|
+
/**
|
|
1217
|
+
* Rebuild a single record in the materialized collection table by replaying
|
|
1218
|
+
* all operations for that record. Called within the applyRemoteOperation
|
|
1219
|
+
* transaction for atomic dual-write.
|
|
1220
|
+
*/
|
|
1221
|
+
rebuildMaterializedRecord(txOrDb, collection, recordId) {
|
|
1222
|
+
const collectionDef = this.schema.collections[collection];
|
|
1223
|
+
if (!collectionDef) return;
|
|
1224
|
+
const ops = txOrDb.select({
|
|
1225
|
+
type: operations.type,
|
|
1226
|
+
data: operations.data,
|
|
1227
|
+
wallTime: operations.wallTime
|
|
1228
|
+
}).from(operations).where(
|
|
1229
|
+
(0, import_drizzle_orm2.and)(
|
|
1230
|
+
(0, import_drizzle_orm2.eq)(operations.collection, collection),
|
|
1231
|
+
(0, import_drizzle_orm2.eq)(operations.recordId, recordId)
|
|
1232
|
+
)
|
|
1233
|
+
).orderBy((0, import_drizzle_orm2.asc)(operations.wallTime), (0, import_drizzle_orm2.asc)(operations.logical), (0, import_drizzle_orm2.asc)(operations.sequenceNumber)).all();
|
|
1234
|
+
const parsedOps = ops.map((op) => ({
|
|
1235
|
+
type: op.type,
|
|
1236
|
+
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1237
|
+
}));
|
|
1238
|
+
const recordData = replayOperationsForRecord(parsedOps);
|
|
1239
|
+
const fieldNames = Object.keys(collectionDef.fields);
|
|
1240
|
+
if (recordData) {
|
|
1241
|
+
const createdAt = ops.length > 0 ? ops[0].wallTime : Date.now();
|
|
1242
|
+
const updatedAt = ops.length > 0 ? ops[ops.length - 1].wallTime : Date.now();
|
|
1243
|
+
this.upsertMaterializedRecord(
|
|
1244
|
+
txOrDb,
|
|
1245
|
+
collection,
|
|
1246
|
+
recordId,
|
|
1247
|
+
recordData,
|
|
1248
|
+
fieldNames,
|
|
1249
|
+
collectionDef,
|
|
1250
|
+
createdAt,
|
|
1251
|
+
updatedAt
|
|
1252
|
+
);
|
|
1253
|
+
} else {
|
|
1254
|
+
txOrDb.run(
|
|
1255
|
+
import_drizzle_orm2.sql`UPDATE ${import_drizzle_orm2.sql.raw(collection)} SET _deleted = 1, _updated_at = ${Date.now()} WHERE id = ${recordId}`
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* UPSERT a record into the materialized collection table.
|
|
1261
|
+
* Uses INSERT ... ON CONFLICT (id) DO UPDATE SET for atomic upsert.
|
|
1262
|
+
*/
|
|
1263
|
+
upsertMaterializedRecord(txOrDb, tableName, recordId, recordData, fieldNames, collectionDef, createdAt, updatedAt) {
|
|
1264
|
+
const allColumns = ["id", ...fieldNames, "_created_at", "_updated_at", "_deleted"];
|
|
1265
|
+
const values = [
|
|
1266
|
+
recordId,
|
|
1267
|
+
...fieldNames.map((f) => {
|
|
1268
|
+
const descriptor = collectionDef.fields[f];
|
|
1269
|
+
return descriptor ? serializeFieldValue(recordData[f] ?? null, descriptor) : null;
|
|
1270
|
+
}),
|
|
1271
|
+
createdAt,
|
|
1272
|
+
updatedAt,
|
|
1273
|
+
0
|
|
1274
|
+
// _deleted = false
|
|
1275
|
+
];
|
|
1276
|
+
const columnsSql = import_drizzle_orm2.sql.raw(allColumns.join(", "));
|
|
1277
|
+
const valuesSql = import_drizzle_orm2.sql.join(
|
|
1278
|
+
values.map((v) => import_drizzle_orm2.sql`${v}`),
|
|
1279
|
+
import_drizzle_orm2.sql.raw(", ")
|
|
1280
|
+
);
|
|
1281
|
+
const updateSet = import_drizzle_orm2.sql.raw(
|
|
1282
|
+
allColumns.slice(1).map((c) => `${c} = excluded.${c}`).join(", ")
|
|
1283
|
+
);
|
|
1284
|
+
txOrDb.run(
|
|
1285
|
+
import_drizzle_orm2.sql`INSERT INTO ${import_drizzle_orm2.sql.raw(tableName)} (${columnsSql}) VALUES (${valuesSql}) ON CONFLICT (id) DO UPDATE SET ${updateSet}`
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Backfill all materialized collection tables from the existing operation log.
|
|
1290
|
+
* Called when setSchema() is invoked and operations already exist.
|
|
1291
|
+
*/
|
|
1292
|
+
async backfillAllCollections() {
|
|
1293
|
+
if (!this.schema) return;
|
|
1294
|
+
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
1295
|
+
this.backfillCollection(collectionName);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Backfill a single collection's materialized table from operations.
|
|
1300
|
+
*/
|
|
1301
|
+
backfillCollection(collectionName) {
|
|
1302
|
+
const collectionDef = this.schema.collections[collectionName];
|
|
1303
|
+
if (!collectionDef) return;
|
|
1304
|
+
const allOps = this.db.select({
|
|
1305
|
+
recordId: operations.recordId,
|
|
1306
|
+
type: operations.type,
|
|
1307
|
+
data: operations.data,
|
|
1308
|
+
wallTime: operations.wallTime
|
|
1309
|
+
}).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();
|
|
1310
|
+
if (allOps.length === 0) return;
|
|
1311
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1312
|
+
for (const op of allOps) {
|
|
1313
|
+
let group = grouped.get(op.recordId);
|
|
1314
|
+
if (!group) {
|
|
1315
|
+
group = [];
|
|
1316
|
+
grouped.set(op.recordId, group);
|
|
1317
|
+
}
|
|
1318
|
+
group.push(op);
|
|
1319
|
+
}
|
|
1320
|
+
const fieldNames = Object.keys(collectionDef.fields);
|
|
1321
|
+
this.db.transaction((tx) => {
|
|
1322
|
+
for (const [recordId, recordOps] of grouped) {
|
|
1323
|
+
const parsedOps = recordOps.map((op) => ({
|
|
1324
|
+
type: op.type,
|
|
1325
|
+
data: op.data !== null ? JSON.parse(op.data) : null
|
|
1326
|
+
}));
|
|
1327
|
+
const recordData = replayOperationsForRecord(parsedOps);
|
|
1328
|
+
if (recordData) {
|
|
1329
|
+
const createdAt = recordOps[0].wallTime;
|
|
1330
|
+
const updatedAt = recordOps[recordOps.length - 1].wallTime;
|
|
1331
|
+
this.upsertMaterializedRecord(
|
|
1332
|
+
tx,
|
|
1333
|
+
collectionName,
|
|
1334
|
+
recordId,
|
|
1335
|
+
recordData,
|
|
1336
|
+
fieldNames,
|
|
1337
|
+
collectionDef,
|
|
1338
|
+
createdAt,
|
|
1339
|
+
updatedAt
|
|
1340
|
+
);
|
|
1341
|
+
} else {
|
|
1342
|
+
tx.run(
|
|
1343
|
+
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()}`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
// Query building
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
buildSelectQuery(collection, options) {
|
|
1353
|
+
const whereClause = this.buildWhereClause(
|
|
1354
|
+
options?.where ?? {},
|
|
1355
|
+
options?.includeDeleted ?? false
|
|
1356
|
+
);
|
|
1357
|
+
const parts = [
|
|
1358
|
+
import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(collection)} WHERE ${whereClause}`
|
|
1359
|
+
];
|
|
1360
|
+
if (options?.orderBy) {
|
|
1361
|
+
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1362
|
+
parts.push(import_drizzle_orm2.sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
1363
|
+
}
|
|
1364
|
+
if (options?.limit !== void 0) {
|
|
1365
|
+
parts.push(import_drizzle_orm2.sql` LIMIT ${options.limit}`);
|
|
1366
|
+
}
|
|
1367
|
+
if (options?.offset !== void 0) {
|
|
1368
|
+
parts.push(import_drizzle_orm2.sql` OFFSET ${options.offset}`);
|
|
1369
|
+
}
|
|
1370
|
+
return import_drizzle_orm2.sql.join(parts, import_drizzle_orm2.sql.raw(""));
|
|
1371
|
+
}
|
|
1372
|
+
buildWhereClause(where, includeDeleted) {
|
|
1373
|
+
const conditions = [];
|
|
1374
|
+
if (!includeDeleted) {
|
|
1375
|
+
conditions.push(import_drizzle_orm2.sql.raw("_deleted = 0"));
|
|
1376
|
+
}
|
|
1377
|
+
for (const [key, value] of Object.entries(where)) {
|
|
1378
|
+
conditions.push(import_drizzle_orm2.sql`${import_drizzle_orm2.sql.raw(key)} = ${value}`);
|
|
1379
|
+
}
|
|
1380
|
+
if (conditions.length === 0) {
|
|
1381
|
+
return import_drizzle_orm2.sql.raw("1 = 1");
|
|
1382
|
+
}
|
|
1383
|
+
return import_drizzle_orm2.sql.join(conditions, import_drizzle_orm2.sql.raw(" AND "));
|
|
1384
|
+
}
|
|
1385
|
+
// ---------------------------------------------------------------------------
|
|
1386
|
+
// Row deserialization
|
|
1387
|
+
// ---------------------------------------------------------------------------
|
|
1388
|
+
deserializeRow(row, collectionDef) {
|
|
1389
|
+
const record = { id: row.id };
|
|
1390
|
+
for (const [fieldName, descriptor] of Object.entries(collectionDef.fields)) {
|
|
1391
|
+
if (fieldName in row) {
|
|
1392
|
+
record[fieldName] = deserializeFieldValue(row[fieldName], descriptor);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if ("_created_at" in row) record._created_at = row._created_at;
|
|
1396
|
+
if ("_updated_at" in row) record._updated_at = row._updated_at;
|
|
1397
|
+
return record;
|
|
1398
|
+
}
|
|
1399
|
+
// ---------------------------------------------------------------------------
|
|
1400
|
+
// Fallback materialization (operation replay, no schema)
|
|
1401
|
+
// ---------------------------------------------------------------------------
|
|
1402
|
+
materializeFromOpsLog(collection) {
|
|
1403
|
+
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();
|
|
1404
|
+
const records = /* @__PURE__ */ new Map();
|
|
1405
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
1406
|
+
for (const row of rows) {
|
|
1407
|
+
const recordId = row.recordId;
|
|
1408
|
+
const data = row.data !== null ? JSON.parse(row.data) : null;
|
|
1409
|
+
switch (row.type) {
|
|
1410
|
+
case "insert":
|
|
1411
|
+
if (data) {
|
|
1412
|
+
records.set(recordId, { id: recordId, ...data });
|
|
1413
|
+
deleted.delete(recordId);
|
|
1414
|
+
}
|
|
1415
|
+
break;
|
|
1416
|
+
case "update":
|
|
1417
|
+
if (data) {
|
|
1418
|
+
const existing = records.get(recordId) ?? { id: recordId };
|
|
1419
|
+
records.set(recordId, { ...existing, ...data });
|
|
1420
|
+
deleted.delete(recordId);
|
|
1421
|
+
}
|
|
1422
|
+
break;
|
|
1423
|
+
case "delete":
|
|
1424
|
+
deleted.add(recordId);
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
for (const id of deleted) {
|
|
1429
|
+
records.delete(id);
|
|
1430
|
+
}
|
|
1431
|
+
return Array.from(records.values());
|
|
1432
|
+
}
|
|
1433
|
+
// ---------------------------------------------------------------------------
|
|
1434
|
+
// Table setup
|
|
1435
|
+
// ---------------------------------------------------------------------------
|
|
433
1436
|
/**
|
|
434
1437
|
* 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
1438
|
*/
|
|
437
1439
|
ensureTables() {
|
|
438
1440
|
this.db.run(import_drizzle_orm2.sql`
|
|
@@ -462,6 +1464,9 @@ var SqliteServerStore = class {
|
|
|
462
1464
|
this.db.run(import_drizzle_orm2.sql`
|
|
463
1465
|
CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)
|
|
464
1466
|
`);
|
|
1467
|
+
this.db.run(import_drizzle_orm2.sql`
|
|
1468
|
+
CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)
|
|
1469
|
+
`);
|
|
465
1470
|
this.db.run(import_drizzle_orm2.sql`
|
|
466
1471
|
CREATE TABLE IF NOT EXISTS sync_state (
|
|
467
1472
|
node_id TEXT PRIMARY KEY,
|
|
@@ -470,6 +1475,9 @@ var SqliteServerStore = class {
|
|
|
470
1475
|
)
|
|
471
1476
|
`);
|
|
472
1477
|
}
|
|
1478
|
+
// ---------------------------------------------------------------------------
|
|
1479
|
+
// Operation serialization
|
|
1480
|
+
// ---------------------------------------------------------------------------
|
|
473
1481
|
serializeOperation(op, receivedAt) {
|
|
474
1482
|
return {
|
|
475
1483
|
id: op.id,
|
|
@@ -507,11 +1515,28 @@ var SqliteServerStore = class {
|
|
|
507
1515
|
schemaVersion: row.schemaVersion
|
|
508
1516
|
};
|
|
509
1517
|
}
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
// Assertions
|
|
1520
|
+
// ---------------------------------------------------------------------------
|
|
510
1521
|
assertOpen() {
|
|
511
1522
|
if (this.closed) {
|
|
512
1523
|
throw new Error("SqliteServerStore is closed");
|
|
513
1524
|
}
|
|
514
1525
|
}
|
|
1526
|
+
assertSchema() {
|
|
1527
|
+
if (!this.schema) {
|
|
1528
|
+
throw new Error(
|
|
1529
|
+
"Schema not set. Call setSchema() before using queryCollection/findRecord/countCollection."
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
assertCollection(collection) {
|
|
1534
|
+
if (!this.schema.collections[collection]) {
|
|
1535
|
+
throw new Error(
|
|
1536
|
+
`Unknown collection "${collection}". Available: ${Object.keys(this.schema.collections).join(", ")}`
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
515
1540
|
};
|
|
516
1541
|
function createSqliteServerStore(options) {
|
|
517
1542
|
const Database = esmRequire("better-sqlite3");
|