@korajs/store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/better-sqlite3.cjs +166 -0
- package/dist/adapters/better-sqlite3.cjs.map +1 -0
- package/dist/adapters/better-sqlite3.d.cts +31 -0
- package/dist/adapters/better-sqlite3.d.ts +31 -0
- package/dist/adapters/better-sqlite3.js +117 -0
- package/dist/adapters/better-sqlite3.js.map +1 -0
- package/dist/adapters/indexeddb.cjs +550 -0
- package/dist/adapters/indexeddb.cjs.map +1 -0
- package/dist/adapters/indexeddb.d.cts +52 -0
- package/dist/adapters/indexeddb.d.ts +52 -0
- package/dist/adapters/indexeddb.js +205 -0
- package/dist/adapters/indexeddb.js.map +1 -0
- package/dist/adapters/sqlite-wasm-worker.cjs +215 -0
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -0
- package/dist/adapters/sqlite-wasm-worker.d.cts +2 -0
- package/dist/adapters/sqlite-wasm-worker.d.ts +2 -0
- package/dist/adapters/sqlite-wasm-worker.js +191 -0
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -0
- package/dist/adapters/sqlite-wasm.cjs +354 -0
- package/dist/adapters/sqlite-wasm.cjs.map +1 -0
- package/dist/adapters/sqlite-wasm.d.cts +68 -0
- package/dist/adapters/sqlite-wasm.d.ts +68 -0
- package/dist/adapters/sqlite-wasm.js +14 -0
- package/dist/adapters/sqlite-wasm.js.map +1 -0
- package/dist/chunk-DXKLAQ6P.js +111 -0
- package/dist/chunk-DXKLAQ6P.js.map +1 -0
- package/dist/chunk-LAWV6CFH.js +62 -0
- package/dist/chunk-LAWV6CFH.js.map +1 -0
- package/dist/chunk-ZP5AXQ3Z.js +179 -0
- package/dist/chunk-ZP5AXQ3Z.js.map +1 -0
- package/dist/index.cjs +1288 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +376 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +1194 -0
- package/dist/index.js.map +1 -0
- package/dist/sqlite-wasm-channel-46AOWNPM.js +10 -0
- package/dist/sqlite-wasm-channel-46AOWNPM.js.map +1 -0
- package/dist/sqlite-wasm-channel-Lakjuk2E.d.cts +104 -0
- package/dist/sqlite-wasm-channel-Lakjuk2E.d.ts +104 -0
- package/dist/types-DF-KDSK1.d.cts +106 -0
- package/dist/types-DF-KDSK1.d.ts +106 -0
- package/package.json +95 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
AdapterError: () => AdapterError,
|
|
34
|
+
Collection: () => Collection,
|
|
35
|
+
PersistenceError: () => PersistenceError,
|
|
36
|
+
QueryBuilder: () => QueryBuilder,
|
|
37
|
+
QueryError: () => QueryError,
|
|
38
|
+
RecordNotFoundError: () => RecordNotFoundError,
|
|
39
|
+
Store: () => Store,
|
|
40
|
+
StoreNotOpenError: () => StoreNotOpenError,
|
|
41
|
+
SubscriptionManager: () => SubscriptionManager,
|
|
42
|
+
WorkerInitError: () => WorkerInitError,
|
|
43
|
+
WorkerTimeoutError: () => WorkerTimeoutError,
|
|
44
|
+
decodeRichtext: () => decodeRichtext,
|
|
45
|
+
encodeRichtext: () => encodeRichtext,
|
|
46
|
+
pluralize: () => pluralize,
|
|
47
|
+
richtextToPlainText: () => richtextToPlainText,
|
|
48
|
+
singularize: () => singularize
|
|
49
|
+
});
|
|
50
|
+
module.exports = __toCommonJS(index_exports);
|
|
51
|
+
|
|
52
|
+
// src/errors.ts
|
|
53
|
+
var import_core = require("@korajs/core");
|
|
54
|
+
var QueryError = class extends import_core.KoraError {
|
|
55
|
+
constructor(message, context) {
|
|
56
|
+
super(message, "QUERY_ERROR", context);
|
|
57
|
+
this.name = "QueryError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var RecordNotFoundError = class extends import_core.KoraError {
|
|
61
|
+
constructor(collection, recordId) {
|
|
62
|
+
super(`Record "${recordId}" not found in collection "${collection}"`, "RECORD_NOT_FOUND", {
|
|
63
|
+
collection,
|
|
64
|
+
recordId
|
|
65
|
+
});
|
|
66
|
+
this.name = "RecordNotFoundError";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var AdapterError = class extends import_core.KoraError {
|
|
70
|
+
constructor(message, context) {
|
|
71
|
+
super(message, "ADAPTER_ERROR", context);
|
|
72
|
+
this.name = "AdapterError";
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var StoreNotOpenError = class extends import_core.KoraError {
|
|
76
|
+
constructor() {
|
|
77
|
+
super("Store is not open. Call store.open() before performing operations.", "STORE_NOT_OPEN");
|
|
78
|
+
this.name = "StoreNotOpenError";
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var WorkerInitError = class extends import_core.KoraError {
|
|
82
|
+
constructor(message, context) {
|
|
83
|
+
super(`Worker initialization failed: ${message}`, "WORKER_INIT_ERROR", context);
|
|
84
|
+
this.name = "WorkerInitError";
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var WorkerTimeoutError = class extends import_core.KoraError {
|
|
88
|
+
constructor(operation, timeoutMs) {
|
|
89
|
+
super(
|
|
90
|
+
`Worker did not respond within ${timeoutMs}ms for operation "${operation}"`,
|
|
91
|
+
"WORKER_TIMEOUT",
|
|
92
|
+
{ operation, timeoutMs }
|
|
93
|
+
);
|
|
94
|
+
this.name = "WorkerTimeoutError";
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var PersistenceError = class extends import_core.KoraError {
|
|
98
|
+
constructor(message, context) {
|
|
99
|
+
super(`Persistence error: ${message}`, "PERSISTENCE_ERROR", context);
|
|
100
|
+
this.name = "PersistenceError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// src/store/store.ts
|
|
105
|
+
var import_core4 = require("@korajs/core");
|
|
106
|
+
|
|
107
|
+
// src/collection/collection.ts
|
|
108
|
+
var import_core3 = require("@korajs/core");
|
|
109
|
+
|
|
110
|
+
// src/query/sql-builder.ts
|
|
111
|
+
function buildSelectQuery(descriptor, fields) {
|
|
112
|
+
const params = [];
|
|
113
|
+
const parts = [`SELECT * FROM ${descriptor.collection}`];
|
|
114
|
+
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
115
|
+
const deletedFilter = "_deleted = 0";
|
|
116
|
+
if (whereClause) {
|
|
117
|
+
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
118
|
+
} else {
|
|
119
|
+
parts.push(`WHERE ${deletedFilter}`);
|
|
120
|
+
}
|
|
121
|
+
if (descriptor.orderBy.length > 0) {
|
|
122
|
+
const orderParts = descriptor.orderBy.map((o) => {
|
|
123
|
+
validateFieldName(o.field, fields);
|
|
124
|
+
return `${o.field} ${o.direction.toUpperCase()}`;
|
|
125
|
+
});
|
|
126
|
+
parts.push(`ORDER BY ${orderParts.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
if (descriptor.limit !== void 0) {
|
|
129
|
+
parts.push(`LIMIT ${descriptor.limit}`);
|
|
130
|
+
}
|
|
131
|
+
if (descriptor.offset !== void 0) {
|
|
132
|
+
parts.push(`OFFSET ${descriptor.offset}`);
|
|
133
|
+
}
|
|
134
|
+
return { sql: parts.join(" "), params };
|
|
135
|
+
}
|
|
136
|
+
function buildCountQuery(descriptor, fields) {
|
|
137
|
+
const params = [];
|
|
138
|
+
const parts = [`SELECT COUNT(*) as count FROM ${descriptor.collection}`];
|
|
139
|
+
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
140
|
+
const deletedFilter = "_deleted = 0";
|
|
141
|
+
if (whereClause) {
|
|
142
|
+
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
143
|
+
} else {
|
|
144
|
+
parts.push(`WHERE ${deletedFilter}`);
|
|
145
|
+
}
|
|
146
|
+
return { sql: parts.join(" "), params };
|
|
147
|
+
}
|
|
148
|
+
function buildInsertQuery(collection, record) {
|
|
149
|
+
const columns = Object.keys(record);
|
|
150
|
+
const placeholders = columns.map(() => "?");
|
|
151
|
+
const params = Object.values(record);
|
|
152
|
+
const sql = `INSERT INTO ${collection} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
153
|
+
return { sql, params };
|
|
154
|
+
}
|
|
155
|
+
function buildUpdateQuery(collection, id, changes) {
|
|
156
|
+
const setClauses = Object.keys(changes).map((col) => `${col} = ?`);
|
|
157
|
+
const params = [...Object.values(changes), id];
|
|
158
|
+
const sql = `UPDATE ${collection} SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
159
|
+
return { sql, params };
|
|
160
|
+
}
|
|
161
|
+
function buildSoftDeleteQuery(collection, id, updatedAt) {
|
|
162
|
+
return {
|
|
163
|
+
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ? WHERE id = ?`,
|
|
164
|
+
params: [updatedAt, id]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
var VALID_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]);
|
|
168
|
+
function buildWhereClauseParts(where, fields, params) {
|
|
169
|
+
const conditions = [];
|
|
170
|
+
for (const [fieldName, value] of Object.entries(where)) {
|
|
171
|
+
validateFieldName(fieldName, fields);
|
|
172
|
+
const descriptor = fields[fieldName];
|
|
173
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
174
|
+
const ops = value;
|
|
175
|
+
for (const [op, opValue] of Object.entries(ops)) {
|
|
176
|
+
if (!VALID_OPERATORS.has(op)) {
|
|
177
|
+
throw new QueryError(`Unknown operator "${op}" on field "${fieldName}"`, {
|
|
178
|
+
field: fieldName,
|
|
179
|
+
operator: op,
|
|
180
|
+
validOperators: [...VALID_OPERATORS]
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
conditions.push(buildOperatorCondition(fieldName, op, opValue, descriptor, params));
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
conditions.push(buildOperatorCondition(fieldName, "$eq", value, descriptor, params));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (conditions.length === 0) return null;
|
|
190
|
+
return conditions.join(" AND ");
|
|
191
|
+
}
|
|
192
|
+
function buildOperatorCondition(fieldName, operator, value, descriptor, params) {
|
|
193
|
+
const sqlValue = descriptor?.kind === "boolean" && typeof value === "boolean" ? value ? 1 : 0 : value;
|
|
194
|
+
switch (operator) {
|
|
195
|
+
case "$eq":
|
|
196
|
+
if (sqlValue === null) {
|
|
197
|
+
return `${fieldName} IS NULL`;
|
|
198
|
+
}
|
|
199
|
+
params.push(sqlValue);
|
|
200
|
+
return `${fieldName} = ?`;
|
|
201
|
+
case "$ne":
|
|
202
|
+
if (sqlValue === null) {
|
|
203
|
+
return `${fieldName} IS NOT NULL`;
|
|
204
|
+
}
|
|
205
|
+
params.push(sqlValue);
|
|
206
|
+
return `${fieldName} != ?`;
|
|
207
|
+
case "$gt":
|
|
208
|
+
params.push(sqlValue);
|
|
209
|
+
return `${fieldName} > ?`;
|
|
210
|
+
case "$gte":
|
|
211
|
+
params.push(sqlValue);
|
|
212
|
+
return `${fieldName} >= ?`;
|
|
213
|
+
case "$lt":
|
|
214
|
+
params.push(sqlValue);
|
|
215
|
+
return `${fieldName} < ?`;
|
|
216
|
+
case "$lte":
|
|
217
|
+
params.push(sqlValue);
|
|
218
|
+
return `${fieldName} <= ?`;
|
|
219
|
+
case "$in": {
|
|
220
|
+
if (!Array.isArray(sqlValue)) {
|
|
221
|
+
throw new QueryError(`$in operator requires an array value for field "${fieldName}"`, {
|
|
222
|
+
field: fieldName,
|
|
223
|
+
received: typeof sqlValue
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const placeholders = sqlValue.map(() => "?");
|
|
227
|
+
for (const item of sqlValue) {
|
|
228
|
+
params.push(
|
|
229
|
+
descriptor?.kind === "boolean" && typeof item === "boolean" ? item ? 1 : 0 : item
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return `${fieldName} IN (${placeholders.join(", ")})`;
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
throw new QueryError(`Unknown operator "${operator}"`, { operator });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function validateFieldName(fieldName, fields) {
|
|
239
|
+
const allowedFields = /* @__PURE__ */ new Set([
|
|
240
|
+
...Object.keys(fields),
|
|
241
|
+
"id",
|
|
242
|
+
"createdAt",
|
|
243
|
+
"updatedAt",
|
|
244
|
+
"_created_at",
|
|
245
|
+
"_updated_at"
|
|
246
|
+
]);
|
|
247
|
+
if (!allowedFields.has(fieldName)) {
|
|
248
|
+
throw new QueryError(
|
|
249
|
+
`Unknown field "${fieldName}" in query. Available fields: ${[...allowedFields].join(", ")}`,
|
|
250
|
+
{ field: fieldName }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/serialization/serializer.ts
|
|
256
|
+
var import_core2 = require("@korajs/core");
|
|
257
|
+
|
|
258
|
+
// src/serialization/richtext-serializer.ts
|
|
259
|
+
var Y = __toESM(require("yjs"), 1);
|
|
260
|
+
var TEXT_KEY = "content";
|
|
261
|
+
function encodeRichtext(value) {
|
|
262
|
+
if (value === null || value === void 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
if (typeof value === "string") {
|
|
266
|
+
const doc = new Y.Doc();
|
|
267
|
+
doc.getText(TEXT_KEY).insert(0, value);
|
|
268
|
+
return Y.encodeStateAsUpdate(doc);
|
|
269
|
+
}
|
|
270
|
+
if (value instanceof Uint8Array) {
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
if (value instanceof ArrayBuffer) {
|
|
274
|
+
return new Uint8Array(value);
|
|
275
|
+
}
|
|
276
|
+
throw new Error("Richtext value must be a string, Uint8Array, ArrayBuffer, null, or undefined.");
|
|
277
|
+
}
|
|
278
|
+
function decodeRichtext(value) {
|
|
279
|
+
if (value === null || value === void 0) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) {
|
|
283
|
+
return new Uint8Array(value);
|
|
284
|
+
}
|
|
285
|
+
if (value instanceof Uint8Array) {
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
if (value instanceof ArrayBuffer) {
|
|
289
|
+
return new Uint8Array(value);
|
|
290
|
+
}
|
|
291
|
+
throw new Error("Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined.");
|
|
292
|
+
}
|
|
293
|
+
function richtextToPlainText(value) {
|
|
294
|
+
const encoded = encodeRichtext(value);
|
|
295
|
+
if (!encoded) return "";
|
|
296
|
+
const doc = new Y.Doc();
|
|
297
|
+
Y.applyUpdate(doc, encoded);
|
|
298
|
+
return doc.getText(TEXT_KEY).toString();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/serialization/serializer.ts
|
|
302
|
+
function serializeRecord(data, fields) {
|
|
303
|
+
const result = {};
|
|
304
|
+
for (const [key, value] of Object.entries(data)) {
|
|
305
|
+
const descriptor = fields[key];
|
|
306
|
+
if (!descriptor) {
|
|
307
|
+
result[key] = value;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
result[key] = serializeValue(value, descriptor);
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
function deserializeRecord(row, fields) {
|
|
315
|
+
const result = {
|
|
316
|
+
id: row.id,
|
|
317
|
+
createdAt: row._created_at,
|
|
318
|
+
updatedAt: row._updated_at
|
|
319
|
+
};
|
|
320
|
+
for (const [key, descriptor] of Object.entries(fields)) {
|
|
321
|
+
const rawValue = row[key];
|
|
322
|
+
if (rawValue === void 0 || rawValue === null) {
|
|
323
|
+
result[key] = rawValue ?? null;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
result[key] = deserializeValue(rawValue, descriptor);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
function serializeOperation(op) {
|
|
331
|
+
return {
|
|
332
|
+
id: op.id,
|
|
333
|
+
node_id: op.nodeId,
|
|
334
|
+
type: op.type,
|
|
335
|
+
record_id: op.recordId,
|
|
336
|
+
data: op.data ? JSON.stringify(op.data) : null,
|
|
337
|
+
previous_data: op.previousData ? JSON.stringify(op.previousData) : null,
|
|
338
|
+
timestamp: import_core2.HybridLogicalClock.serialize(op.timestamp),
|
|
339
|
+
sequence_number: op.sequenceNumber,
|
|
340
|
+
causal_deps: JSON.stringify(op.causalDeps),
|
|
341
|
+
schema_version: op.schemaVersion
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function deserializeOperation(row) {
|
|
345
|
+
return {
|
|
346
|
+
id: row.id,
|
|
347
|
+
nodeId: row.node_id,
|
|
348
|
+
type: row.type,
|
|
349
|
+
collection: "",
|
|
350
|
+
// Collection name is derived from the table name by the caller
|
|
351
|
+
recordId: row.record_id,
|
|
352
|
+
data: row.data ? JSON.parse(row.data) : null,
|
|
353
|
+
previousData: row.previous_data ? JSON.parse(row.previous_data) : null,
|
|
354
|
+
timestamp: import_core2.HybridLogicalClock.deserialize(row.timestamp),
|
|
355
|
+
sequenceNumber: row.sequence_number,
|
|
356
|
+
causalDeps: JSON.parse(row.causal_deps),
|
|
357
|
+
schemaVersion: row.schema_version
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function deserializeOperationWithCollection(row, collection) {
|
|
361
|
+
const op = deserializeOperation(row);
|
|
362
|
+
return { ...op, collection };
|
|
363
|
+
}
|
|
364
|
+
function serializeValue(value, descriptor) {
|
|
365
|
+
if (value === null || value === void 0) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
switch (descriptor.kind) {
|
|
369
|
+
case "boolean":
|
|
370
|
+
return value ? 1 : 0;
|
|
371
|
+
case "array":
|
|
372
|
+
return JSON.stringify(value);
|
|
373
|
+
case "richtext":
|
|
374
|
+
return encodeRichtext(value);
|
|
375
|
+
default:
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function deserializeValue(value, descriptor) {
|
|
380
|
+
switch (descriptor.kind) {
|
|
381
|
+
case "boolean":
|
|
382
|
+
return value === 1 || value === true;
|
|
383
|
+
case "array":
|
|
384
|
+
if (typeof value === "string") {
|
|
385
|
+
return JSON.parse(value);
|
|
386
|
+
}
|
|
387
|
+
return value;
|
|
388
|
+
case "richtext":
|
|
389
|
+
return decodeRichtext(value);
|
|
390
|
+
default:
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/collection/collection.ts
|
|
396
|
+
var Collection = class {
|
|
397
|
+
constructor(name, definition, schema, adapter, clock, nodeId, getSequenceNumber, onMutation) {
|
|
398
|
+
this.name = name;
|
|
399
|
+
this.definition = definition;
|
|
400
|
+
this.schema = schema;
|
|
401
|
+
this.adapter = adapter;
|
|
402
|
+
this.clock = clock;
|
|
403
|
+
this.nodeId = nodeId;
|
|
404
|
+
this.getSequenceNumber = getSequenceNumber;
|
|
405
|
+
this.onMutation = onMutation;
|
|
406
|
+
}
|
|
407
|
+
name;
|
|
408
|
+
definition;
|
|
409
|
+
schema;
|
|
410
|
+
adapter;
|
|
411
|
+
clock;
|
|
412
|
+
nodeId;
|
|
413
|
+
getSequenceNumber;
|
|
414
|
+
onMutation;
|
|
415
|
+
/**
|
|
416
|
+
* Insert a new record into the collection.
|
|
417
|
+
* Generates a UUID v7 for the id, validates data, and persists atomically.
|
|
418
|
+
*
|
|
419
|
+
* @param data - The record data (auto fields and defaults are applied automatically)
|
|
420
|
+
* @returns The inserted record with id, createdAt, updatedAt
|
|
421
|
+
*/
|
|
422
|
+
async insert(data) {
|
|
423
|
+
const validated = (0, import_core3.validateRecord)(this.name, this.definition, data, "insert");
|
|
424
|
+
const recordId = (0, import_core3.generateUUIDv7)();
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
for (const [fieldName, descriptor] of Object.entries(this.definition.fields)) {
|
|
427
|
+
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
428
|
+
validated[fieldName] = now;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const sequenceNumber = this.getSequenceNumber();
|
|
432
|
+
const operation = await (0, import_core3.createOperation)(
|
|
433
|
+
{
|
|
434
|
+
nodeId: this.nodeId,
|
|
435
|
+
type: "insert",
|
|
436
|
+
collection: this.name,
|
|
437
|
+
recordId,
|
|
438
|
+
data: { ...validated },
|
|
439
|
+
previousData: null,
|
|
440
|
+
sequenceNumber,
|
|
441
|
+
causalDeps: [],
|
|
442
|
+
schemaVersion: this.schema.version
|
|
443
|
+
},
|
|
444
|
+
this.clock
|
|
445
|
+
);
|
|
446
|
+
const serializedData = serializeRecord(validated, this.definition.fields);
|
|
447
|
+
const record = {
|
|
448
|
+
id: recordId,
|
|
449
|
+
...serializedData,
|
|
450
|
+
_created_at: now,
|
|
451
|
+
_updated_at: now
|
|
452
|
+
};
|
|
453
|
+
const insertQuery = buildInsertQuery(this.name, record);
|
|
454
|
+
const opRow = serializeOperation(operation);
|
|
455
|
+
const opInsert = buildInsertQuery(
|
|
456
|
+
`_kora_ops_${this.name}`,
|
|
457
|
+
opRow
|
|
458
|
+
);
|
|
459
|
+
await this.adapter.transaction(async (tx) => {
|
|
460
|
+
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
461
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
462
|
+
await tx.execute(
|
|
463
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
464
|
+
[this.nodeId, sequenceNumber]
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
this.onMutation(this.name, operation);
|
|
468
|
+
return {
|
|
469
|
+
id: recordId,
|
|
470
|
+
...validated,
|
|
471
|
+
createdAt: now,
|
|
472
|
+
updatedAt: now
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Find a record by its ID. Returns null if not found or soft-deleted.
|
|
477
|
+
*/
|
|
478
|
+
async findById(id) {
|
|
479
|
+
const rows = await this.adapter.query(
|
|
480
|
+
`SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
|
|
481
|
+
[id]
|
|
482
|
+
);
|
|
483
|
+
const row = rows[0];
|
|
484
|
+
if (!row) return null;
|
|
485
|
+
return deserializeRecord(row, this.definition.fields);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Update an existing record. Only the provided fields are changed.
|
|
489
|
+
*
|
|
490
|
+
* @param id - The record ID to update
|
|
491
|
+
* @param data - Partial data with only the fields to change
|
|
492
|
+
* @returns The updated record
|
|
493
|
+
* @throws {RecordNotFoundError} If the record doesn't exist or is deleted
|
|
494
|
+
*/
|
|
495
|
+
async update(id, data) {
|
|
496
|
+
const currentRows = await this.adapter.query(
|
|
497
|
+
`SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
|
|
498
|
+
[id]
|
|
499
|
+
);
|
|
500
|
+
const currentRow = currentRows[0];
|
|
501
|
+
if (!currentRow) {
|
|
502
|
+
throw new RecordNotFoundError(this.name, id);
|
|
503
|
+
}
|
|
504
|
+
const validated = (0, import_core3.validateRecord)(this.name, this.definition, data, "update");
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
const previousData = {};
|
|
507
|
+
const currentRecord = deserializeRecord(currentRow, this.definition.fields);
|
|
508
|
+
for (const key of Object.keys(validated)) {
|
|
509
|
+
previousData[key] = currentRecord[key];
|
|
510
|
+
}
|
|
511
|
+
const sequenceNumber = this.getSequenceNumber();
|
|
512
|
+
const operation = await (0, import_core3.createOperation)(
|
|
513
|
+
{
|
|
514
|
+
nodeId: this.nodeId,
|
|
515
|
+
type: "update",
|
|
516
|
+
collection: this.name,
|
|
517
|
+
recordId: id,
|
|
518
|
+
data: { ...validated },
|
|
519
|
+
previousData,
|
|
520
|
+
sequenceNumber,
|
|
521
|
+
causalDeps: [],
|
|
522
|
+
schemaVersion: this.schema.version
|
|
523
|
+
},
|
|
524
|
+
this.clock
|
|
525
|
+
);
|
|
526
|
+
const serializedChanges = serializeRecord(validated, this.definition.fields);
|
|
527
|
+
const updateQuery = buildUpdateQuery(this.name, id, {
|
|
528
|
+
...serializedChanges,
|
|
529
|
+
_updated_at: now
|
|
530
|
+
});
|
|
531
|
+
const opRow = serializeOperation(operation);
|
|
532
|
+
const opInsert = buildInsertQuery(
|
|
533
|
+
`_kora_ops_${this.name}`,
|
|
534
|
+
opRow
|
|
535
|
+
);
|
|
536
|
+
await this.adapter.transaction(async (tx) => {
|
|
537
|
+
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
538
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
539
|
+
await tx.execute(
|
|
540
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
541
|
+
[this.nodeId, sequenceNumber]
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
this.onMutation(this.name, operation);
|
|
545
|
+
const updatedRow = await this.findById(id);
|
|
546
|
+
if (!updatedRow) {
|
|
547
|
+
throw new RecordNotFoundError(this.name, id);
|
|
548
|
+
}
|
|
549
|
+
return updatedRow;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Soft-delete a record by its ID.
|
|
553
|
+
*
|
|
554
|
+
* @param id - The record ID to delete
|
|
555
|
+
* @throws {RecordNotFoundError} If the record doesn't exist or is already deleted
|
|
556
|
+
*/
|
|
557
|
+
async delete(id) {
|
|
558
|
+
const currentRows = await this.adapter.query(
|
|
559
|
+
`SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
|
|
560
|
+
[id]
|
|
561
|
+
);
|
|
562
|
+
if (!currentRows[0]) {
|
|
563
|
+
throw new RecordNotFoundError(this.name, id);
|
|
564
|
+
}
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const sequenceNumber = this.getSequenceNumber();
|
|
567
|
+
const operation = await (0, import_core3.createOperation)(
|
|
568
|
+
{
|
|
569
|
+
nodeId: this.nodeId,
|
|
570
|
+
type: "delete",
|
|
571
|
+
collection: this.name,
|
|
572
|
+
recordId: id,
|
|
573
|
+
data: null,
|
|
574
|
+
previousData: null,
|
|
575
|
+
sequenceNumber,
|
|
576
|
+
causalDeps: [],
|
|
577
|
+
schemaVersion: this.schema.version
|
|
578
|
+
},
|
|
579
|
+
this.clock
|
|
580
|
+
);
|
|
581
|
+
const deleteQuery = buildSoftDeleteQuery(this.name, id, now);
|
|
582
|
+
const opRow = serializeOperation(operation);
|
|
583
|
+
const opInsert = buildInsertQuery(
|
|
584
|
+
`_kora_ops_${this.name}`,
|
|
585
|
+
opRow
|
|
586
|
+
);
|
|
587
|
+
await this.adapter.transaction(async (tx) => {
|
|
588
|
+
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
589
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
590
|
+
await tx.execute(
|
|
591
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
592
|
+
[this.nodeId, sequenceNumber]
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
this.onMutation(this.name, operation);
|
|
596
|
+
}
|
|
597
|
+
/** Get the collection name */
|
|
598
|
+
getName() {
|
|
599
|
+
return this.name;
|
|
600
|
+
}
|
|
601
|
+
/** Get the collection definition */
|
|
602
|
+
getDefinition() {
|
|
603
|
+
return this.definition;
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// src/query/pluralize.ts
|
|
608
|
+
function isVowel(char) {
|
|
609
|
+
if (!char) return false;
|
|
610
|
+
return "aeiouAEIOU".includes(char);
|
|
611
|
+
}
|
|
612
|
+
function pluralize(word) {
|
|
613
|
+
if (word.endsWith("s")) return word;
|
|
614
|
+
if (word.endsWith("y") && !isVowel(word[word.length - 2])) {
|
|
615
|
+
return `${word.slice(0, -1)}ies`;
|
|
616
|
+
}
|
|
617
|
+
if (word.endsWith("sh") || word.endsWith("ch") || word.endsWith("x") || word.endsWith("z")) {
|
|
618
|
+
return `${word}es`;
|
|
619
|
+
}
|
|
620
|
+
return `${word}s`;
|
|
621
|
+
}
|
|
622
|
+
function singularize(word) {
|
|
623
|
+
if (word.endsWith("ies") && !isVowel(word[word.length - 4])) {
|
|
624
|
+
return `${word.slice(0, -3)}y`;
|
|
625
|
+
}
|
|
626
|
+
if (word.endsWith("shes") || word.endsWith("ches") || word.endsWith("xes") || word.endsWith("zes")) {
|
|
627
|
+
return word.slice(0, -2);
|
|
628
|
+
}
|
|
629
|
+
if (word.endsWith("ses")) {
|
|
630
|
+
return word.slice(0, -2);
|
|
631
|
+
}
|
|
632
|
+
if (word.endsWith("s") && !word.endsWith("ss")) {
|
|
633
|
+
return word.slice(0, -1);
|
|
634
|
+
}
|
|
635
|
+
return word;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/query/query-builder.ts
|
|
639
|
+
var QueryBuilder = class _QueryBuilder {
|
|
640
|
+
constructor(collectionName, definition, adapter, subscriptionManager, initialWhere = {}, schema) {
|
|
641
|
+
this.collectionName = collectionName;
|
|
642
|
+
this.definition = definition;
|
|
643
|
+
this.adapter = adapter;
|
|
644
|
+
this.subscriptionManager = subscriptionManager;
|
|
645
|
+
this.schema = schema;
|
|
646
|
+
this.descriptor = {
|
|
647
|
+
collection: collectionName,
|
|
648
|
+
where: { ...initialWhere },
|
|
649
|
+
orderBy: []
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
collectionName;
|
|
653
|
+
definition;
|
|
654
|
+
adapter;
|
|
655
|
+
subscriptionManager;
|
|
656
|
+
schema;
|
|
657
|
+
descriptor;
|
|
658
|
+
/**
|
|
659
|
+
* Add WHERE conditions (AND semantics, merged with existing conditions).
|
|
660
|
+
*/
|
|
661
|
+
where(conditions) {
|
|
662
|
+
const clone = this.clone();
|
|
663
|
+
clone.descriptor = {
|
|
664
|
+
...clone.descriptor,
|
|
665
|
+
where: { ...clone.descriptor.where, ...conditions }
|
|
666
|
+
};
|
|
667
|
+
return clone;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Add ORDER BY clause.
|
|
671
|
+
*/
|
|
672
|
+
orderBy(field, direction = "asc") {
|
|
673
|
+
const clone = this.clone();
|
|
674
|
+
clone.descriptor = {
|
|
675
|
+
...clone.descriptor,
|
|
676
|
+
orderBy: [...clone.descriptor.orderBy, { field, direction }]
|
|
677
|
+
};
|
|
678
|
+
return clone;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Set result limit.
|
|
682
|
+
*/
|
|
683
|
+
limit(n) {
|
|
684
|
+
const clone = this.clone();
|
|
685
|
+
clone.descriptor = { ...clone.descriptor, limit: n };
|
|
686
|
+
return clone;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Set result offset.
|
|
690
|
+
*/
|
|
691
|
+
offset(n) {
|
|
692
|
+
const clone = this.clone();
|
|
693
|
+
clone.descriptor = { ...clone.descriptor, offset: n };
|
|
694
|
+
return clone;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Include related records in the query results.
|
|
698
|
+
* Follows relations defined in the schema to batch-fetch related data.
|
|
699
|
+
*
|
|
700
|
+
* @param targets - Relation target names (collection names or relation names)
|
|
701
|
+
* @returns A new QueryBuilder with include targets added
|
|
702
|
+
*
|
|
703
|
+
* @example
|
|
704
|
+
* ```typescript
|
|
705
|
+
* const todosWithProject = await app.todos
|
|
706
|
+
* .where({ completed: false })
|
|
707
|
+
* .include('project')
|
|
708
|
+
* .exec()
|
|
709
|
+
* ```
|
|
710
|
+
*/
|
|
711
|
+
include(...targets) {
|
|
712
|
+
const clone = this.clone();
|
|
713
|
+
const existing = clone.descriptor.include ?? [];
|
|
714
|
+
clone.descriptor = {
|
|
715
|
+
...clone.descriptor,
|
|
716
|
+
include: [...existing, ...targets]
|
|
717
|
+
};
|
|
718
|
+
return clone;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Execute the query and return results.
|
|
722
|
+
*/
|
|
723
|
+
async exec() {
|
|
724
|
+
const { sql, params } = buildSelectQuery(this.descriptor, this.definition.fields);
|
|
725
|
+
const rows = await this.adapter.query(sql, params);
|
|
726
|
+
const records = rows.map((row) => deserializeRecord(row, this.definition.fields));
|
|
727
|
+
if (this.descriptor.include && this.descriptor.include.length > 0 && this.schema) {
|
|
728
|
+
await this.resolveIncludes(records);
|
|
729
|
+
}
|
|
730
|
+
return records;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Execute a COUNT query and return the count.
|
|
734
|
+
*/
|
|
735
|
+
async count() {
|
|
736
|
+
const { sql, params } = buildCountQuery(this.descriptor, this.definition.fields);
|
|
737
|
+
const rows = await this.adapter.query(sql, params);
|
|
738
|
+
return rows[0]?.count ?? 0;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Subscribe to query results. Callback is called immediately with current results,
|
|
742
|
+
* then again whenever the results change due to mutations.
|
|
743
|
+
*
|
|
744
|
+
* @returns An unsubscribe function
|
|
745
|
+
*/
|
|
746
|
+
subscribe(callback) {
|
|
747
|
+
const executeFn = () => this.exec();
|
|
748
|
+
const descriptorCopy = { ...this.descriptor };
|
|
749
|
+
if (descriptorCopy.include && descriptorCopy.include.length > 0 && this.schema) {
|
|
750
|
+
descriptorCopy.includeCollections = this.resolveIncludeCollections(descriptorCopy.include);
|
|
751
|
+
}
|
|
752
|
+
return this.subscriptionManager.registerAndFetch(
|
|
753
|
+
descriptorCopy,
|
|
754
|
+
callback,
|
|
755
|
+
executeFn
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
/** Get the internal descriptor (for testing/debugging) */
|
|
759
|
+
getDescriptor() {
|
|
760
|
+
return { ...this.descriptor };
|
|
761
|
+
}
|
|
762
|
+
clone() {
|
|
763
|
+
const qb = new _QueryBuilder(
|
|
764
|
+
this.collectionName,
|
|
765
|
+
this.definition,
|
|
766
|
+
this.adapter,
|
|
767
|
+
this.subscriptionManager,
|
|
768
|
+
{},
|
|
769
|
+
this.schema
|
|
770
|
+
);
|
|
771
|
+
qb.descriptor = {
|
|
772
|
+
...this.descriptor,
|
|
773
|
+
where: { ...this.descriptor.where },
|
|
774
|
+
orderBy: [...this.descriptor.orderBy],
|
|
775
|
+
include: this.descriptor.include ? [...this.descriptor.include] : void 0,
|
|
776
|
+
includeCollections: this.descriptor.includeCollections ? [...this.descriptor.includeCollections] : void 0
|
|
777
|
+
};
|
|
778
|
+
return qb;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Resolve include targets to their actual collection names for subscription tracking.
|
|
782
|
+
*/
|
|
783
|
+
resolveIncludeCollections(targets) {
|
|
784
|
+
if (!this.schema) return [];
|
|
785
|
+
const collections = [];
|
|
786
|
+
for (const target of targets) {
|
|
787
|
+
const relation = this.findRelation(target);
|
|
788
|
+
if (relation) {
|
|
789
|
+
if (relation.from === this.collectionName) {
|
|
790
|
+
collections.push(relation.to);
|
|
791
|
+
} else {
|
|
792
|
+
collections.push(relation.from);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return collections;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Resolve includes after primary query, batch-fetching related records.
|
|
800
|
+
*/
|
|
801
|
+
async resolveIncludes(records) {
|
|
802
|
+
if (!this.schema || !this.descriptor.include || records.length === 0) return;
|
|
803
|
+
for (const target of this.descriptor.include) {
|
|
804
|
+
const relation = this.findRelation(target);
|
|
805
|
+
if (!relation) {
|
|
806
|
+
throw new QueryError2(
|
|
807
|
+
`No relation found for include target "${target}" on collection "${this.collectionName}". Check that a relation is defined in your schema that connects "${this.collectionName}" to "${target}".`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (relation.from === this.collectionName) {
|
|
811
|
+
await this.resolveManyToOneInclude(records, relation, target);
|
|
812
|
+
} else {
|
|
813
|
+
await this.resolveOneToManyInclude(records, relation, target);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Many-to-one: collect FK values from primary results, batch-fetch related, attach as singular.
|
|
819
|
+
*/
|
|
820
|
+
async resolveManyToOneInclude(records, relation, target) {
|
|
821
|
+
const fkField = relation.field;
|
|
822
|
+
const fkValues = records.map((r) => r[fkField]).filter((v) => v !== null && v !== void 0 && typeof v === "string");
|
|
823
|
+
if (fkValues.length === 0) {
|
|
824
|
+
const propName2 = singularize(target);
|
|
825
|
+
for (const record of records) {
|
|
826
|
+
;
|
|
827
|
+
record[propName2] = null;
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const uniqueFks = [...new Set(fkValues)];
|
|
832
|
+
const relatedCollection = relation.to;
|
|
833
|
+
const relatedDef = this.schema?.collections[relatedCollection];
|
|
834
|
+
if (!relatedDef) return;
|
|
835
|
+
const placeholders = uniqueFks.map(() => "?").join(", ");
|
|
836
|
+
const sql = `SELECT * FROM ${relatedCollection} WHERE id IN (${placeholders}) AND _deleted = 0`;
|
|
837
|
+
const rows = await this.adapter.query(sql, uniqueFks);
|
|
838
|
+
const relatedRecords = rows.map((row) => deserializeRecord(row, relatedDef.fields));
|
|
839
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
840
|
+
for (const r of relatedRecords) {
|
|
841
|
+
lookup.set(r.id, r);
|
|
842
|
+
}
|
|
843
|
+
const propName = singularize(target);
|
|
844
|
+
for (const record of records) {
|
|
845
|
+
const fk = record[fkField];
|
|
846
|
+
record[propName] = fk ? lookup.get(fk) ?? null : null;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* One-to-many: collect primary IDs, batch-fetch children, attach as array.
|
|
851
|
+
*/
|
|
852
|
+
async resolveOneToManyInclude(records, relation, target) {
|
|
853
|
+
const primaryIds = records.map((r) => r.id);
|
|
854
|
+
const relatedCollection = relation.from;
|
|
855
|
+
const relatedDef = this.schema?.collections[relatedCollection];
|
|
856
|
+
if (!relatedDef) return;
|
|
857
|
+
const fkField = relation.field;
|
|
858
|
+
const placeholders = primaryIds.map(() => "?").join(", ");
|
|
859
|
+
const sql = `SELECT * FROM ${relatedCollection} WHERE ${fkField} IN (${placeholders}) AND _deleted = 0`;
|
|
860
|
+
const rows = await this.adapter.query(sql, primaryIds);
|
|
861
|
+
const relatedRecords = rows.map((row) => deserializeRecord(row, relatedDef.fields));
|
|
862
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
863
|
+
for (const r of relatedRecords) {
|
|
864
|
+
const fk = r[fkField];
|
|
865
|
+
if (!grouped.has(fk)) {
|
|
866
|
+
grouped.set(fk, []);
|
|
867
|
+
}
|
|
868
|
+
grouped.get(fk)?.push(r);
|
|
869
|
+
}
|
|
870
|
+
const propName = pluralize(target);
|
|
871
|
+
for (const record of records) {
|
|
872
|
+
;
|
|
873
|
+
record[propName] = grouped.get(record.id) ?? [];
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Find a relation definition matching the include target.
|
|
878
|
+
* Searches by relation name, target collection name, and singularized/pluralized variants.
|
|
879
|
+
*/
|
|
880
|
+
findRelation(target) {
|
|
881
|
+
if (!this.schema) return null;
|
|
882
|
+
for (const [_name, rel] of Object.entries(this.schema.relations)) {
|
|
883
|
+
if (rel.from === this.collectionName && rel.to === target) return rel;
|
|
884
|
+
if (rel.to === this.collectionName && rel.from === target) return rel;
|
|
885
|
+
if (rel.from === this.collectionName && rel.to === pluralize(target)) return rel;
|
|
886
|
+
if (rel.to === this.collectionName && rel.from === pluralize(target)) return rel;
|
|
887
|
+
if (rel.from === this.collectionName && rel.to === singularize(target)) return rel;
|
|
888
|
+
if (rel.to === this.collectionName && rel.from === singularize(target)) return rel;
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
var QueryError2 = class extends Error {
|
|
894
|
+
constructor(message) {
|
|
895
|
+
super(message);
|
|
896
|
+
this.name = "QueryError";
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
// src/subscription/subscription-manager.ts
|
|
901
|
+
var nextSubId = 0;
|
|
902
|
+
var SubscriptionManager = class {
|
|
903
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
904
|
+
pendingCollections = /* @__PURE__ */ new Set();
|
|
905
|
+
flushScheduled = false;
|
|
906
|
+
/**
|
|
907
|
+
* Register a new subscription.
|
|
908
|
+
*
|
|
909
|
+
* @param descriptor - The query descriptor defining what this subscription watches
|
|
910
|
+
* @param callback - Called with results whenever they change
|
|
911
|
+
* @param executeFn - Function to re-execute the query and get current results
|
|
912
|
+
* @returns An unsubscribe function
|
|
913
|
+
*/
|
|
914
|
+
register(descriptor, callback, executeFn) {
|
|
915
|
+
const id = `sub_${++nextSubId}`;
|
|
916
|
+
const subscription = {
|
|
917
|
+
id,
|
|
918
|
+
descriptor,
|
|
919
|
+
callback,
|
|
920
|
+
executeFn,
|
|
921
|
+
lastResults: []
|
|
922
|
+
};
|
|
923
|
+
this.subscriptions.set(id, subscription);
|
|
924
|
+
return () => {
|
|
925
|
+
this.subscriptions.delete(id);
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Register a subscription and immediately execute the query.
|
|
930
|
+
* The initial results are stored as lastResults so subsequent flushes
|
|
931
|
+
* correctly diff against the initial state.
|
|
932
|
+
*
|
|
933
|
+
* @returns An unsubscribe function
|
|
934
|
+
*/
|
|
935
|
+
registerAndFetch(descriptor, callback, executeFn) {
|
|
936
|
+
const id = `sub_${++nextSubId}`;
|
|
937
|
+
const subscription = {
|
|
938
|
+
id,
|
|
939
|
+
descriptor,
|
|
940
|
+
callback,
|
|
941
|
+
executeFn,
|
|
942
|
+
lastResults: []
|
|
943
|
+
};
|
|
944
|
+
this.subscriptions.set(id, subscription);
|
|
945
|
+
executeFn().then((results) => {
|
|
946
|
+
if (this.subscriptions.has(id)) {
|
|
947
|
+
subscription.lastResults = results;
|
|
948
|
+
callback(results);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
return () => {
|
|
952
|
+
this.subscriptions.delete(id);
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Notify the manager that a mutation occurred on a collection.
|
|
957
|
+
* Schedules a microtask flush to batch multiple mutations in the same tick.
|
|
958
|
+
*/
|
|
959
|
+
notify(collection, _operation) {
|
|
960
|
+
this.pendingCollections.add(collection);
|
|
961
|
+
this.scheduleFlush();
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Immediately flush all pending notifications.
|
|
965
|
+
* Useful for testing. In production, flushing happens via microtask.
|
|
966
|
+
*/
|
|
967
|
+
async flush() {
|
|
968
|
+
if (this.pendingCollections.size === 0) return;
|
|
969
|
+
const collections = new Set(this.pendingCollections);
|
|
970
|
+
this.pendingCollections.clear();
|
|
971
|
+
this.flushScheduled = false;
|
|
972
|
+
const affected = [];
|
|
973
|
+
for (const sub of this.subscriptions.values()) {
|
|
974
|
+
if (collections.has(sub.descriptor.collection)) {
|
|
975
|
+
affected.push(sub);
|
|
976
|
+
} else if (sub.descriptor.includeCollections) {
|
|
977
|
+
for (const incCol of sub.descriptor.includeCollections) {
|
|
978
|
+
if (collections.has(incCol)) {
|
|
979
|
+
affected.push(sub);
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
for (const sub of affected) {
|
|
986
|
+
try {
|
|
987
|
+
const newResults = await sub.executeFn();
|
|
988
|
+
if (!this.resultsEqual(sub.lastResults, newResults)) {
|
|
989
|
+
sub.lastResults = newResults;
|
|
990
|
+
sub.callback(newResults);
|
|
991
|
+
}
|
|
992
|
+
} catch {
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Remove all subscriptions. Called on store close.
|
|
998
|
+
*/
|
|
999
|
+
clear() {
|
|
1000
|
+
this.subscriptions.clear();
|
|
1001
|
+
this.pendingCollections.clear();
|
|
1002
|
+
this.flushScheduled = false;
|
|
1003
|
+
}
|
|
1004
|
+
/** Number of active subscriptions (for testing/debugging) */
|
|
1005
|
+
get size() {
|
|
1006
|
+
return this.subscriptions.size;
|
|
1007
|
+
}
|
|
1008
|
+
scheduleFlush() {
|
|
1009
|
+
if (this.flushScheduled) return;
|
|
1010
|
+
this.flushScheduled = true;
|
|
1011
|
+
queueMicrotask(() => {
|
|
1012
|
+
this.flush();
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Compare two result sets. Uses length check + JSON comparison as pragmatic approach.
|
|
1017
|
+
* Sufficient for typical query results. Can be optimized to id-based diffing if profiling shows need.
|
|
1018
|
+
*/
|
|
1019
|
+
resultsEqual(prev, next) {
|
|
1020
|
+
if (prev.length !== next.length) return false;
|
|
1021
|
+
if (prev.length === 0) return true;
|
|
1022
|
+
return JSON.stringify(prev) === JSON.stringify(next);
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
// src/store/store.ts
|
|
1027
|
+
var Store = class {
|
|
1028
|
+
opened = false;
|
|
1029
|
+
nodeId = "";
|
|
1030
|
+
sequenceNumber = 0;
|
|
1031
|
+
versionVector = (0, import_core4.createVersionVector)();
|
|
1032
|
+
clock = null;
|
|
1033
|
+
collections = /* @__PURE__ */ new Map();
|
|
1034
|
+
subscriptionManager = new SubscriptionManager();
|
|
1035
|
+
schema;
|
|
1036
|
+
adapter;
|
|
1037
|
+
configNodeId;
|
|
1038
|
+
emitter;
|
|
1039
|
+
constructor(config) {
|
|
1040
|
+
this.schema = config.schema;
|
|
1041
|
+
this.adapter = config.adapter;
|
|
1042
|
+
this.configNodeId = config.nodeId;
|
|
1043
|
+
this.emitter = config.emitter ?? null;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Open the store: initialize the database, load or generate a node ID,
|
|
1047
|
+
* restore the sequence number and version vector, and create Collection instances.
|
|
1048
|
+
*/
|
|
1049
|
+
async open() {
|
|
1050
|
+
await this.adapter.open(this.schema);
|
|
1051
|
+
this.nodeId = await this.loadOrGenerateNodeId();
|
|
1052
|
+
this.clock = new import_core4.HybridLogicalClock(this.nodeId);
|
|
1053
|
+
this.sequenceNumber = await this.loadSequenceNumber();
|
|
1054
|
+
this.versionVector = await this.loadVersionVector();
|
|
1055
|
+
for (const [name, definition] of Object.entries(this.schema.collections)) {
|
|
1056
|
+
const col = new Collection(
|
|
1057
|
+
name,
|
|
1058
|
+
definition,
|
|
1059
|
+
this.schema,
|
|
1060
|
+
this.adapter,
|
|
1061
|
+
this.clock,
|
|
1062
|
+
this.nodeId,
|
|
1063
|
+
() => this.nextSequenceNumber(),
|
|
1064
|
+
(collectionName, operation) => {
|
|
1065
|
+
this.subscriptionManager.notify(collectionName, operation);
|
|
1066
|
+
if (this.emitter) {
|
|
1067
|
+
this.emitter.emit({ type: "operation:created", operation });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
);
|
|
1071
|
+
this.collections.set(name, col);
|
|
1072
|
+
}
|
|
1073
|
+
this.opened = true;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Close the store: clear subscriptions and close the adapter.
|
|
1077
|
+
*/
|
|
1078
|
+
async close() {
|
|
1079
|
+
this.subscriptionManager.clear();
|
|
1080
|
+
this.collections.clear();
|
|
1081
|
+
this.opened = false;
|
|
1082
|
+
await this.adapter.close();
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Get a Collection instance for CRUD operations.
|
|
1086
|
+
* @throws {StoreNotOpenError} If the store is not open
|
|
1087
|
+
* @throws {Error} If the collection name is not in the schema
|
|
1088
|
+
*/
|
|
1089
|
+
collection(name) {
|
|
1090
|
+
this.ensureOpen();
|
|
1091
|
+
const col = this.collections.get(name);
|
|
1092
|
+
if (!col) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`Unknown collection "${name}". Available: ${[...this.collections.keys()].join(", ")}`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
const definition = this.schema.collections[name];
|
|
1098
|
+
if (!definition) {
|
|
1099
|
+
throw new Error(`Collection definition not found for "${name}"`);
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
insert: (data) => col.insert(data),
|
|
1103
|
+
findById: (id) => col.findById(id),
|
|
1104
|
+
update: (id, data) => col.update(id, data),
|
|
1105
|
+
delete: (id) => col.delete(id),
|
|
1106
|
+
where: (conditions) => new QueryBuilder(name, definition, this.adapter, this.subscriptionManager, conditions, this.schema)
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Get the current version vector.
|
|
1111
|
+
*/
|
|
1112
|
+
getVersionVector() {
|
|
1113
|
+
this.ensureOpen();
|
|
1114
|
+
return new Map(this.versionVector);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Get the node ID for this store instance.
|
|
1118
|
+
*/
|
|
1119
|
+
getNodeId() {
|
|
1120
|
+
this.ensureOpen();
|
|
1121
|
+
return this.nodeId;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Apply a remote operation received from sync.
|
|
1125
|
+
* Checks for duplicates, applies to the data table, persists the operation,
|
|
1126
|
+
* and updates the version vector.
|
|
1127
|
+
*/
|
|
1128
|
+
async applyRemoteOperation(op) {
|
|
1129
|
+
this.ensureOpen();
|
|
1130
|
+
const collection = op.collection;
|
|
1131
|
+
const definition = this.schema.collections[collection];
|
|
1132
|
+
if (!definition) {
|
|
1133
|
+
return "skipped";
|
|
1134
|
+
}
|
|
1135
|
+
const existing = await this.adapter.query(
|
|
1136
|
+
`SELECT id FROM _kora_ops_${collection} WHERE id = ?`,
|
|
1137
|
+
[op.id]
|
|
1138
|
+
);
|
|
1139
|
+
if (existing.length > 0) {
|
|
1140
|
+
return "duplicate";
|
|
1141
|
+
}
|
|
1142
|
+
if (this.clock) {
|
|
1143
|
+
this.clock.receive(op.timestamp);
|
|
1144
|
+
}
|
|
1145
|
+
await this.adapter.transaction(async (tx) => {
|
|
1146
|
+
if (op.type === "insert" && op.data) {
|
|
1147
|
+
const serializedData = serializeRecord(op.data, definition.fields);
|
|
1148
|
+
const now = op.timestamp.wallTime;
|
|
1149
|
+
const record = {
|
|
1150
|
+
id: op.recordId,
|
|
1151
|
+
...serializedData,
|
|
1152
|
+
_created_at: now,
|
|
1153
|
+
_updated_at: now
|
|
1154
|
+
};
|
|
1155
|
+
const insertQuery = buildInsertQuery(collection, record);
|
|
1156
|
+
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
1157
|
+
} else if (op.type === "update" && op.data) {
|
|
1158
|
+
const serializedChanges = serializeRecord(op.data, definition.fields);
|
|
1159
|
+
const updateQuery = buildUpdateQuery(collection, op.recordId, {
|
|
1160
|
+
...serializedChanges,
|
|
1161
|
+
_updated_at: op.timestamp.wallTime
|
|
1162
|
+
});
|
|
1163
|
+
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
1164
|
+
} else if (op.type === "delete") {
|
|
1165
|
+
const deleteQuery = buildSoftDeleteQuery(collection, op.recordId, op.timestamp.wallTime);
|
|
1166
|
+
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
1167
|
+
}
|
|
1168
|
+
const opRow = serializeOperation(op);
|
|
1169
|
+
const opInsert = buildInsertQuery(
|
|
1170
|
+
`_kora_ops_${collection}`,
|
|
1171
|
+
opRow
|
|
1172
|
+
);
|
|
1173
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
1174
|
+
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
1175
|
+
if (op.sequenceNumber > currentSeq) {
|
|
1176
|
+
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
1177
|
+
await tx.execute(
|
|
1178
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1179
|
+
[op.nodeId, op.sequenceNumber]
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
this.subscriptionManager.notify(collection, op);
|
|
1184
|
+
return "applied";
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Get operations from a node within a sequence number range.
|
|
1188
|
+
* Implements the OperationLog interface for computeDelta.
|
|
1189
|
+
*/
|
|
1190
|
+
getRange(nodeId, fromSeq, toSeq) {
|
|
1191
|
+
return [];
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Async version of getRange for use by the sync layer.
|
|
1195
|
+
*/
|
|
1196
|
+
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
1197
|
+
this.ensureOpen();
|
|
1198
|
+
const allOps = [];
|
|
1199
|
+
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
1200
|
+
const rows = await this.adapter.query(
|
|
1201
|
+
`SELECT * FROM _kora_ops_${collectionName} WHERE node_id = ? AND sequence_number >= ? AND sequence_number <= ? ORDER BY sequence_number ASC`,
|
|
1202
|
+
[nodeId, fromSeq, toSeq]
|
|
1203
|
+
);
|
|
1204
|
+
for (const row of rows) {
|
|
1205
|
+
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
allOps.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
1209
|
+
return allOps;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Get the schema definition.
|
|
1213
|
+
*/
|
|
1214
|
+
getSchema() {
|
|
1215
|
+
return this.schema;
|
|
1216
|
+
}
|
|
1217
|
+
/** Expose the subscription manager for direct access (e.g., by QueryBuilder) */
|
|
1218
|
+
getSubscriptionManager() {
|
|
1219
|
+
return this.subscriptionManager;
|
|
1220
|
+
}
|
|
1221
|
+
nextSequenceNumber() {
|
|
1222
|
+
this.sequenceNumber++;
|
|
1223
|
+
this.versionVector.set(this.nodeId, this.sequenceNumber);
|
|
1224
|
+
return this.sequenceNumber;
|
|
1225
|
+
}
|
|
1226
|
+
async loadOrGenerateNodeId() {
|
|
1227
|
+
if (this.configNodeId) {
|
|
1228
|
+
await this.adapter.execute(
|
|
1229
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)",
|
|
1230
|
+
[this.configNodeId]
|
|
1231
|
+
);
|
|
1232
|
+
return this.configNodeId;
|
|
1233
|
+
}
|
|
1234
|
+
const rows = await this.adapter.query(
|
|
1235
|
+
"SELECT value FROM _kora_meta WHERE key = 'node_id'"
|
|
1236
|
+
);
|
|
1237
|
+
if (rows[0]) {
|
|
1238
|
+
return rows[0].value;
|
|
1239
|
+
}
|
|
1240
|
+
const newNodeId = (0, import_core4.generateUUIDv7)();
|
|
1241
|
+
await this.adapter.execute("INSERT INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
|
|
1242
|
+
newNodeId
|
|
1243
|
+
]);
|
|
1244
|
+
return newNodeId;
|
|
1245
|
+
}
|
|
1246
|
+
async loadSequenceNumber() {
|
|
1247
|
+
const rows = await this.adapter.query(
|
|
1248
|
+
"SELECT sequence_number FROM _kora_version_vector WHERE node_id = ?",
|
|
1249
|
+
[this.nodeId]
|
|
1250
|
+
);
|
|
1251
|
+
return rows[0]?.sequence_number ?? 0;
|
|
1252
|
+
}
|
|
1253
|
+
async loadVersionVector() {
|
|
1254
|
+
const rows = await this.adapter.query(
|
|
1255
|
+
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
1256
|
+
);
|
|
1257
|
+
const vector = (0, import_core4.createVersionVector)();
|
|
1258
|
+
for (const row of rows) {
|
|
1259
|
+
vector.set(row.node_id, row.sequence_number);
|
|
1260
|
+
}
|
|
1261
|
+
return vector;
|
|
1262
|
+
}
|
|
1263
|
+
ensureOpen() {
|
|
1264
|
+
if (!this.opened) {
|
|
1265
|
+
throw new StoreNotOpenError();
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1270
|
+
0 && (module.exports = {
|
|
1271
|
+
AdapterError,
|
|
1272
|
+
Collection,
|
|
1273
|
+
PersistenceError,
|
|
1274
|
+
QueryBuilder,
|
|
1275
|
+
QueryError,
|
|
1276
|
+
RecordNotFoundError,
|
|
1277
|
+
Store,
|
|
1278
|
+
StoreNotOpenError,
|
|
1279
|
+
SubscriptionManager,
|
|
1280
|
+
WorkerInitError,
|
|
1281
|
+
WorkerTimeoutError,
|
|
1282
|
+
decodeRichtext,
|
|
1283
|
+
encodeRichtext,
|
|
1284
|
+
pluralize,
|
|
1285
|
+
richtextToPlainText,
|
|
1286
|
+
singularize
|
|
1287
|
+
});
|
|
1288
|
+
//# sourceMappingURL=index.cjs.map
|