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