@datrix/adapter-json 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/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/index.d.mts +88 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +2330 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2321 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +60 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2321 @@
|
|
|
1
|
+
// src/adapter.ts
|
|
2
|
+
import fs2 from "fs/promises";
|
|
3
|
+
import path2 from "path";
|
|
4
|
+
import { validateQueryObject } from "@datrix/core";
|
|
5
|
+
|
|
6
|
+
// src/runner.ts
|
|
7
|
+
import {
|
|
8
|
+
throwInvalidRelationWhereSyntax,
|
|
9
|
+
throwInvalidWhereField
|
|
10
|
+
} from "@datrix/core";
|
|
11
|
+
var JsonQueryRunner = class {
|
|
12
|
+
constructor(table, adapter, schema) {
|
|
13
|
+
this.table = table;
|
|
14
|
+
this.adapter = adapter;
|
|
15
|
+
this.schema = schema;
|
|
16
|
+
}
|
|
17
|
+
schema;
|
|
18
|
+
get tableData() {
|
|
19
|
+
return this.table;
|
|
20
|
+
}
|
|
21
|
+
get tableSchema() {
|
|
22
|
+
return this.schema;
|
|
23
|
+
}
|
|
24
|
+
get adapterRef() {
|
|
25
|
+
return this.adapter;
|
|
26
|
+
}
|
|
27
|
+
async run(query) {
|
|
28
|
+
let result = this.table.data;
|
|
29
|
+
if (query.where) {
|
|
30
|
+
const matchResults = await Promise.all(
|
|
31
|
+
result.map((item) => this.match(item, query.where))
|
|
32
|
+
);
|
|
33
|
+
result = result.filter((_, i) => matchResults[i]);
|
|
34
|
+
} else if (query.type === "select" && query.orderBy && query.orderBy.length > 0) {
|
|
35
|
+
result = [...result];
|
|
36
|
+
}
|
|
37
|
+
if (query.type === "count") {
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
if (query.select || query.distinct) {
|
|
41
|
+
result = this.project(result, query.select, query.distinct);
|
|
42
|
+
}
|
|
43
|
+
if (query.orderBy && query.orderBy.length > 0) {
|
|
44
|
+
result.sort(
|
|
45
|
+
(a, b) => this.sort(
|
|
46
|
+
a,
|
|
47
|
+
b,
|
|
48
|
+
query.orderBy
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const offset = query.offset ?? 0;
|
|
53
|
+
if (query.limit !== void 0) {
|
|
54
|
+
result = result.slice(offset, offset + query.limit);
|
|
55
|
+
} else if (offset > 0) {
|
|
56
|
+
result = result.slice(offset);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run query without projection (for populate workflow)
|
|
62
|
+
* Applies WHERE, ORDER BY, OFFSET, LIMIT but keeps all fields
|
|
63
|
+
*/
|
|
64
|
+
async filterAndSort(query) {
|
|
65
|
+
let result = this.table.data;
|
|
66
|
+
if (query.where) {
|
|
67
|
+
const matchResults = await Promise.all(
|
|
68
|
+
result.map((item) => this.match(item, query.where))
|
|
69
|
+
);
|
|
70
|
+
result = result.filter((_, i) => matchResults[i]);
|
|
71
|
+
} else if (query.orderBy && query.orderBy.length > 0) {
|
|
72
|
+
result = [...result];
|
|
73
|
+
}
|
|
74
|
+
if (query.orderBy && query.orderBy.length > 0) {
|
|
75
|
+
result.sort(
|
|
76
|
+
(a, b) => this.sort(
|
|
77
|
+
a,
|
|
78
|
+
b,
|
|
79
|
+
query.orderBy
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const offset = query.offset ?? 0;
|
|
84
|
+
if (query.limit !== void 0) {
|
|
85
|
+
result = result.slice(offset, offset + query.limit);
|
|
86
|
+
} else if (offset > 0) {
|
|
87
|
+
result = result.slice(offset);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
// Exposed for Adapter's RETURNING clause usage
|
|
92
|
+
projectData(data, select, distinct) {
|
|
93
|
+
return this.project(data, select, distinct);
|
|
94
|
+
}
|
|
95
|
+
project(data, select, distinct) {
|
|
96
|
+
let result = data;
|
|
97
|
+
if (select && select !== "*") {
|
|
98
|
+
result = data.map((item) => {
|
|
99
|
+
const projected = {};
|
|
100
|
+
for (const field of select) {
|
|
101
|
+
projected[field] = item[field];
|
|
102
|
+
if (projected[field] === void 0) {
|
|
103
|
+
projected[field] = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return projected;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (distinct) {
|
|
110
|
+
const seen = /* @__PURE__ */ new Set();
|
|
111
|
+
result = result.filter((item) => {
|
|
112
|
+
const key = JSON.stringify(item);
|
|
113
|
+
if (seen.has(key)) return false;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Match WHERE clause against an item
|
|
122
|
+
*
|
|
123
|
+
* **NEW:** Now supports nested relation WHERE queries!
|
|
124
|
+
*
|
|
125
|
+
* @param item - The record to match against
|
|
126
|
+
* @param where - WHERE clause (may contain nested relation conditions)
|
|
127
|
+
* @param overrideSchema - Optional schema to use instead of this.table.schema (for nested relation matching)
|
|
128
|
+
* @returns True if item matches all conditions
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* // Simple WHERE
|
|
133
|
+
* await match(item, { price: { $gt: 10 } })
|
|
134
|
+
*
|
|
135
|
+
* // Nested relation WHERE
|
|
136
|
+
* await match(item, {
|
|
137
|
+
* author: { // Relation field
|
|
138
|
+
* verified: { $eq: true }
|
|
139
|
+
* }
|
|
140
|
+
* })
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
async match(item, where, overrideSchema) {
|
|
144
|
+
const schema = overrideSchema ?? this.schema;
|
|
145
|
+
for (const [key, value] of Object.entries(where)) {
|
|
146
|
+
if (key === "$and") {
|
|
147
|
+
const results = await Promise.all(
|
|
148
|
+
value.map(
|
|
149
|
+
(cond) => this.match(item, cond, schema)
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
if (!results.every((r) => r)) return false;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (key === "$or") {
|
|
156
|
+
const results = await Promise.all(
|
|
157
|
+
value.map(
|
|
158
|
+
(cond) => this.match(item, cond, schema)
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
if (!results.some((r) => r)) return false;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (key === "$not") {
|
|
165
|
+
if (await this.match(item, value, schema))
|
|
166
|
+
return false;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const fieldDef = schema?.fields?.[key];
|
|
170
|
+
if (fieldDef?.type === "relation") {
|
|
171
|
+
const matched = await this.matchRelation(
|
|
172
|
+
item,
|
|
173
|
+
key,
|
|
174
|
+
value,
|
|
175
|
+
fieldDef
|
|
176
|
+
);
|
|
177
|
+
if (!matched) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (schema && !schema.fields[key]) {
|
|
183
|
+
throwInvalidWhereField({
|
|
184
|
+
adapter: "json",
|
|
185
|
+
field: key,
|
|
186
|
+
schemaName: schema.name,
|
|
187
|
+
availableFields: Object.keys(schema.fields)
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const itemValue = item[key];
|
|
191
|
+
if (value === null) {
|
|
192
|
+
if (itemValue !== null && itemValue !== void 0) return false;
|
|
193
|
+
} else if (typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)) {
|
|
194
|
+
const isOperators = Object.keys(value).some((k) => k.startsWith("$"));
|
|
195
|
+
if (isOperators) {
|
|
196
|
+
if (!this.matchOperators(itemValue, value, key))
|
|
197
|
+
return false;
|
|
198
|
+
} else {
|
|
199
|
+
if (!this.compareValues(itemValue, value, key)) return false;
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
if (!this.compareValues(itemValue, value, key)) return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Match nested relation WHERE
|
|
209
|
+
*
|
|
210
|
+
* Loads the related record(s) and recursively matches the nested WHERE clause.
|
|
211
|
+
*
|
|
212
|
+
* @param item - Current record
|
|
213
|
+
* @param relationName - Name of the relation field
|
|
214
|
+
* @param relationWhere - Nested WHERE clause for the relation
|
|
215
|
+
* @param relationField - Relation field definition
|
|
216
|
+
* @returns True if relation matches
|
|
217
|
+
*/
|
|
218
|
+
async matchRelation(item, relationName, relationWhere, relationField) {
|
|
219
|
+
const foreignKey = relationField.foreignKey;
|
|
220
|
+
const targetModelName = relationField.model;
|
|
221
|
+
const kind = relationField.kind;
|
|
222
|
+
if (typeof relationWhere === "object" && relationWhere !== null) {
|
|
223
|
+
const keys = Object.keys(relationWhere);
|
|
224
|
+
const logicalOps = /* @__PURE__ */ new Set(["$and", "$or", "$not"]);
|
|
225
|
+
const hasOnlyComparisonOperators = keys.length > 0 && keys.every((k) => k.startsWith("$")) && !keys.some((k) => logicalOps.has(k));
|
|
226
|
+
if (hasOnlyComparisonOperators) {
|
|
227
|
+
throwInvalidRelationWhereSyntax({
|
|
228
|
+
adapter: "json",
|
|
229
|
+
relationName,
|
|
230
|
+
schemaName: this.schema?.name ?? "unknown",
|
|
231
|
+
foreignKey
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (kind === "belongsTo" || kind === "hasOne") {
|
|
236
|
+
const relatedId = item[foreignKey];
|
|
237
|
+
if (relatedId === null || relatedId === void 0) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
const targetSchema = await this.adapter.getSchemaByModelName(targetModelName);
|
|
241
|
+
if (!targetSchema) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const relatedRecord = await this.loadRelatedRecord(
|
|
245
|
+
targetModelName,
|
|
246
|
+
relatedId
|
|
247
|
+
);
|
|
248
|
+
if (!relatedRecord) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return await this.match(relatedRecord, relationWhere, targetSchema);
|
|
252
|
+
}
|
|
253
|
+
if (kind === "hasMany") {
|
|
254
|
+
const sourceId = item["id"];
|
|
255
|
+
if (sourceId === null || sourceId === void 0) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
const targetSchema = await this.adapter.getSchemaByModelName(targetModelName);
|
|
259
|
+
if (!targetSchema) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const targetTable = targetSchema.tableName ?? targetModelName.toLowerCase();
|
|
263
|
+
const targetTableData = await this.adapter.getCachedTable(targetTable);
|
|
264
|
+
if (!targetTableData) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
const relatedRecords = targetTableData.data.filter(
|
|
268
|
+
(r) => r[foreignKey] === sourceId || r[foreignKey] === Number(sourceId)
|
|
269
|
+
);
|
|
270
|
+
for (const related of relatedRecords) {
|
|
271
|
+
const matches = await this.match(related, relationWhere, targetSchema);
|
|
272
|
+
if (matches) return true;
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (kind === "manyToMany") {
|
|
277
|
+
const junctionTableName = relationField.through;
|
|
278
|
+
if (!junctionTableName) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const sourceId = item["id"];
|
|
282
|
+
if (sourceId === null || sourceId === void 0) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
const junctionTableData = await this.adapter.getCachedTable(junctionTableName);
|
|
286
|
+
if (!junctionTableData) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
const currentModelName = this.schema?.name ?? "";
|
|
290
|
+
const sourceFK = `${currentModelName}Id`;
|
|
291
|
+
const targetFK = `${targetModelName}Id`;
|
|
292
|
+
const targetIds = junctionTableData.data.filter((row) => {
|
|
293
|
+
const rowSourceId = row[sourceFK];
|
|
294
|
+
return rowSourceId === sourceId || rowSourceId === Number(sourceId);
|
|
295
|
+
}).map((row) => {
|
|
296
|
+
const rawId = row[targetFK];
|
|
297
|
+
return typeof rawId === "string" ? Number(rawId) : rawId;
|
|
298
|
+
}).filter((id) => id !== null && id !== void 0);
|
|
299
|
+
if (targetIds.length === 0) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const targetSchema = await this.adapter.getSchemaByModelName(targetModelName);
|
|
303
|
+
if (!targetSchema) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const targetTable = targetSchema.tableName ?? targetModelName.toLowerCase();
|
|
307
|
+
const targetTableData = await this.adapter.getCachedTable(targetTable);
|
|
308
|
+
if (!targetTableData) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
const targetRecords = targetTableData.data.filter((r) => targetIds.includes(r["id"]));
|
|
312
|
+
for (const target of targetRecords) {
|
|
313
|
+
const matches = await this.match(target, relationWhere, targetSchema);
|
|
314
|
+
if (matches) return true;
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Load a related record from adapter's cache
|
|
322
|
+
*
|
|
323
|
+
* Uses getCachedTable which reads from cache or disk if needed.
|
|
324
|
+
*
|
|
325
|
+
* @param modelName - Target model name
|
|
326
|
+
* @param id - Record ID to load
|
|
327
|
+
* @returns Related record or null
|
|
328
|
+
*/
|
|
329
|
+
async loadRelatedRecord(modelName, id) {
|
|
330
|
+
try {
|
|
331
|
+
const targetSchema = await this.adapter.getSchemaByModelName(modelName);
|
|
332
|
+
if (!targetSchema) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const targetTable = targetSchema.tableName ?? modelName.toLowerCase();
|
|
336
|
+
const tableData = await this.adapter.getCachedTable(targetTable);
|
|
337
|
+
if (!tableData) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const relatedData = tableData.data;
|
|
341
|
+
const record = relatedData.find((r) => r["id"] === id);
|
|
342
|
+
return record ?? null;
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
compareValues(itemValue, queryValue, fieldName) {
|
|
348
|
+
const schema = this.schema;
|
|
349
|
+
const fieldDef = schema?.fields?.[fieldName];
|
|
350
|
+
if (!fieldDef) {
|
|
351
|
+
return itemValue === queryValue;
|
|
352
|
+
}
|
|
353
|
+
const fieldType = fieldDef.type;
|
|
354
|
+
if (fieldType === "number") {
|
|
355
|
+
const itemNum = Number(itemValue);
|
|
356
|
+
const queryNum = Number(queryValue);
|
|
357
|
+
return !isNaN(itemNum) && !isNaN(queryNum) && itemNum === queryNum;
|
|
358
|
+
}
|
|
359
|
+
if (fieldType === "string") {
|
|
360
|
+
return String(itemValue) === String(queryValue);
|
|
361
|
+
}
|
|
362
|
+
if (fieldType === "boolean") {
|
|
363
|
+
return Boolean(itemValue) === Boolean(queryValue);
|
|
364
|
+
}
|
|
365
|
+
return itemValue === queryValue;
|
|
366
|
+
}
|
|
367
|
+
matchOperators(value, operators, fieldName) {
|
|
368
|
+
for (const [op, opValue] of Object.entries(operators)) {
|
|
369
|
+
switch (op) {
|
|
370
|
+
case "$eq":
|
|
371
|
+
if (!this.compareValues(value, opValue, fieldName)) return false;
|
|
372
|
+
break;
|
|
373
|
+
case "$ne":
|
|
374
|
+
if (this.compareValues(value, opValue, fieldName)) return false;
|
|
375
|
+
break;
|
|
376
|
+
case "$gt": {
|
|
377
|
+
if (value === null || value === void 0) return false;
|
|
378
|
+
const coercedVal = this.coerceForComparison(value, fieldName);
|
|
379
|
+
const coercedOp = this.coerceForComparison(opValue, fieldName);
|
|
380
|
+
if (!(coercedVal > coercedOp)) return false;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
case "$gte": {
|
|
384
|
+
if (value === null || value === void 0) return false;
|
|
385
|
+
const coercedVal = this.coerceForComparison(value, fieldName);
|
|
386
|
+
const coercedOp = this.coerceForComparison(opValue, fieldName);
|
|
387
|
+
if (!(coercedVal >= coercedOp)) return false;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case "$lt": {
|
|
391
|
+
if (value === null || value === void 0) return false;
|
|
392
|
+
const coercedVal = this.coerceForComparison(value, fieldName);
|
|
393
|
+
const coercedOp = this.coerceForComparison(opValue, fieldName);
|
|
394
|
+
if (!(coercedVal < coercedOp)) return false;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "$lte": {
|
|
398
|
+
if (value === null || value === void 0) return false;
|
|
399
|
+
const coercedVal = this.coerceForComparison(value, fieldName);
|
|
400
|
+
const coercedOp = this.coerceForComparison(opValue, fieldName);
|
|
401
|
+
if (!(coercedVal <= coercedOp)) return false;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case "$in": {
|
|
405
|
+
const coercedValue = this.coerceForComparison(value, fieldName);
|
|
406
|
+
const coercedArray = opValue.map(
|
|
407
|
+
(v) => this.coerceForComparison(v, fieldName)
|
|
408
|
+
);
|
|
409
|
+
if (!coercedArray.includes(coercedValue)) return false;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case "$nin": {
|
|
413
|
+
const coercedValue = this.coerceForComparison(value, fieldName);
|
|
414
|
+
const coercedArray = opValue.map(
|
|
415
|
+
(v) => this.coerceForComparison(v, fieldName)
|
|
416
|
+
);
|
|
417
|
+
if (coercedArray.includes(coercedValue)) return false;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "$exists":
|
|
421
|
+
if (opValue && (value === void 0 || value === null)) return false;
|
|
422
|
+
if (!opValue && value !== void 0 && value !== null) return false;
|
|
423
|
+
break;
|
|
424
|
+
case "$null":
|
|
425
|
+
if (opValue && value !== null && value !== void 0) return false;
|
|
426
|
+
if (!opValue && (value === null || value === void 0)) return false;
|
|
427
|
+
break;
|
|
428
|
+
case "$like":
|
|
429
|
+
case "$ilike": {
|
|
430
|
+
const pattern = opValue.replace(/%/g, ".*").replace(/_/g, ".");
|
|
431
|
+
const flags = op === "$ilike" ? "i" : "";
|
|
432
|
+
const regex = new RegExp(`^${pattern}$`, flags);
|
|
433
|
+
if (!regex.test(String(value ?? ""))) return false;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case "$contains":
|
|
437
|
+
if (!String(value ?? "").includes(String(opValue))) return false;
|
|
438
|
+
break;
|
|
439
|
+
case "$notContains":
|
|
440
|
+
if (String(value ?? "").includes(String(opValue))) return false;
|
|
441
|
+
break;
|
|
442
|
+
case "$startsWith":
|
|
443
|
+
if (!String(value ?? "").startsWith(String(opValue))) return false;
|
|
444
|
+
break;
|
|
445
|
+
case "$endsWith":
|
|
446
|
+
if (!String(value ?? "").endsWith(String(opValue))) return false;
|
|
447
|
+
break;
|
|
448
|
+
case "$notNull":
|
|
449
|
+
if (opValue && (value === null || value === void 0)) return false;
|
|
450
|
+
if (!opValue && value !== null && value !== void 0) return false;
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
coerceForComparison(value, fieldName) {
|
|
457
|
+
if (value === null || value === void 0) {
|
|
458
|
+
return value;
|
|
459
|
+
}
|
|
460
|
+
const schema = this.schema;
|
|
461
|
+
const fieldDef = schema?.fields?.[fieldName];
|
|
462
|
+
if (!fieldDef) return value;
|
|
463
|
+
const fieldType = fieldDef.type;
|
|
464
|
+
if (fieldType === "number") {
|
|
465
|
+
const num = Number(value);
|
|
466
|
+
return isNaN(num) ? value : num;
|
|
467
|
+
}
|
|
468
|
+
if (fieldType === "string") {
|
|
469
|
+
return String(value);
|
|
470
|
+
}
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
sort(a, b, orderBy) {
|
|
474
|
+
for (const order of orderBy) {
|
|
475
|
+
const fieldName = order.field;
|
|
476
|
+
const valA = this.coerceForComparison(a[fieldName], fieldName);
|
|
477
|
+
const valB = this.coerceForComparison(b[fieldName], fieldName);
|
|
478
|
+
if (valA === valB) continue;
|
|
479
|
+
const direction = order.direction === "asc" ? 1 : -1;
|
|
480
|
+
if (valA === null || valA === void 0)
|
|
481
|
+
return order.nulls === "first" ? -1 : 1;
|
|
482
|
+
if (valB === null || valB === void 0)
|
|
483
|
+
return order.nulls === "first" ? 1 : -1;
|
|
484
|
+
if (valA < valB) return -1 * direction;
|
|
485
|
+
if (valA > valB) return 1 * direction;
|
|
486
|
+
}
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/lock.ts
|
|
492
|
+
import { throwLockTimeout } from "@datrix/core";
|
|
493
|
+
import fs from "fs/promises";
|
|
494
|
+
import path from "path";
|
|
495
|
+
var SimpleLock = class {
|
|
496
|
+
lockPath;
|
|
497
|
+
lockTimeout;
|
|
498
|
+
// How long to wait to acquire lock
|
|
499
|
+
staleTimeout;
|
|
500
|
+
// How long a lock is valid
|
|
501
|
+
constructor(root, lockTimeout = 5e3, staleTimeout = 3e4) {
|
|
502
|
+
this.lockPath = path.join(root, "db.lock");
|
|
503
|
+
this.lockTimeout = lockTimeout;
|
|
504
|
+
this.staleTimeout = staleTimeout;
|
|
505
|
+
}
|
|
506
|
+
async acquire() {
|
|
507
|
+
const start = Date.now();
|
|
508
|
+
while (true) {
|
|
509
|
+
try {
|
|
510
|
+
await fs.writeFile(this.lockPath, Date.now().toString(), {
|
|
511
|
+
flag: "wx"
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code !== "EEXIST") {
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
const isStale = await this.checkStale();
|
|
519
|
+
if (isStale) {
|
|
520
|
+
try {
|
|
521
|
+
await fs.unlink(this.lockPath);
|
|
522
|
+
continue;
|
|
523
|
+
} catch (unlinkError) {
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (Date.now() - start > this.lockTimeout) {
|
|
527
|
+
throwLockTimeout({ adapter: "json", lockTimeout: this.lockTimeout });
|
|
528
|
+
}
|
|
529
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async release() {
|
|
534
|
+
try {
|
|
535
|
+
await fs.unlink(this.lockPath);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error.code !== "ENOENT") {
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async checkStale() {
|
|
542
|
+
try {
|
|
543
|
+
const content = await fs.readFile(this.lockPath, "utf-8");
|
|
544
|
+
const timestamp = parseInt(content, 10);
|
|
545
|
+
if (isNaN(timestamp)) return true;
|
|
546
|
+
const age = Date.now() - timestamp;
|
|
547
|
+
return age > this.staleTimeout;
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// src/adapter.ts
|
|
555
|
+
import {
|
|
556
|
+
DatrixAdapterError as DatrixAdapterError2,
|
|
557
|
+
throwNotConnected,
|
|
558
|
+
throwConnectionError,
|
|
559
|
+
throwMigrationError as throwMigrationError2,
|
|
560
|
+
throwTransactionError,
|
|
561
|
+
throwQueryError,
|
|
562
|
+
throwMetaFieldAlreadyExists,
|
|
563
|
+
throwMetaFieldNotFound
|
|
564
|
+
} from "@datrix/core";
|
|
565
|
+
|
|
566
|
+
// src/transaction.ts
|
|
567
|
+
import {
|
|
568
|
+
throwTransactionAlreadyCommitted,
|
|
569
|
+
throwTransactionAlreadyRolledBack,
|
|
570
|
+
throwTransactionSavepointNotSupported,
|
|
571
|
+
throwRawQueryNotSupported
|
|
572
|
+
} from "@datrix/core";
|
|
573
|
+
var JsonTransaction = class {
|
|
574
|
+
constructor(adapter, commitCallback, rollbackCallback) {
|
|
575
|
+
this.adapter = adapter;
|
|
576
|
+
this.commitCallback = commitCallback;
|
|
577
|
+
this.rollbackCallback = rollbackCallback;
|
|
578
|
+
this.id = `tx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
579
|
+
}
|
|
580
|
+
id;
|
|
581
|
+
committed = false;
|
|
582
|
+
rolledBack = false;
|
|
583
|
+
/**
|
|
584
|
+
* Check if transaction is still active
|
|
585
|
+
*/
|
|
586
|
+
assertActive() {
|
|
587
|
+
if (this.committed) {
|
|
588
|
+
throwTransactionAlreadyCommitted({ adapter: "json" });
|
|
589
|
+
}
|
|
590
|
+
if (this.rolledBack) {
|
|
591
|
+
throwTransactionAlreadyRolledBack({ adapter: "json" });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Execute query within transaction
|
|
596
|
+
*
|
|
597
|
+
* Uses adapter's executeQueryWithOptions with skipLock and skipWrite.
|
|
598
|
+
* Adapter automatically uses transaction cache.
|
|
599
|
+
*/
|
|
600
|
+
async executeQuery(query) {
|
|
601
|
+
this.assertActive();
|
|
602
|
+
return this.adapter.executeQueryWithOptions(query, {
|
|
603
|
+
skipLock: true,
|
|
604
|
+
skipWrite: true
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Execute raw SQL query (not supported for JSON adapter)
|
|
609
|
+
*/
|
|
610
|
+
async executeRawQuery(_sql, _params) {
|
|
611
|
+
throwRawQueryNotSupported({ adapter: "json" });
|
|
612
|
+
}
|
|
613
|
+
// ========================================
|
|
614
|
+
// SchemaOperations implementation
|
|
615
|
+
// ========================================
|
|
616
|
+
/**
|
|
617
|
+
* Create table within transaction
|
|
618
|
+
*/
|
|
619
|
+
async createTable(schema) {
|
|
620
|
+
this.assertActive();
|
|
621
|
+
return this.adapter.createTableWithOptions(schema, { skipWrite: true });
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Drop table within transaction
|
|
625
|
+
*/
|
|
626
|
+
async dropTable(tableName) {
|
|
627
|
+
this.assertActive();
|
|
628
|
+
return this.adapter.dropTableWithOptions(tableName, { skipWrite: true });
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Rename table within transaction
|
|
632
|
+
*/
|
|
633
|
+
async renameTable(from, to) {
|
|
634
|
+
this.assertActive();
|
|
635
|
+
return this.adapter.renameTableWithOptions(from, to, { skipWrite: true });
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Alter table within transaction
|
|
639
|
+
*/
|
|
640
|
+
async alterTable(tableName, operations) {
|
|
641
|
+
this.assertActive();
|
|
642
|
+
return this.adapter.alterTableWithOptions(tableName, operations, {
|
|
643
|
+
skipWrite: true
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Add index within transaction
|
|
648
|
+
*/
|
|
649
|
+
async addIndex(tableName, index) {
|
|
650
|
+
this.assertActive();
|
|
651
|
+
return this.adapter.addIndexWithOptions(tableName, index, {
|
|
652
|
+
skipWrite: true
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Drop index within transaction
|
|
657
|
+
*/
|
|
658
|
+
async dropIndex(tableName, indexName) {
|
|
659
|
+
this.assertActive();
|
|
660
|
+
return this.adapter.dropIndexWithOptions(tableName, indexName, {
|
|
661
|
+
skipWrite: true
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Commit transaction
|
|
666
|
+
*
|
|
667
|
+
* Delegates to adapter's commitTransaction which writes to disk.
|
|
668
|
+
*/
|
|
669
|
+
async commit() {
|
|
670
|
+
this.assertActive();
|
|
671
|
+
await this.commitCallback();
|
|
672
|
+
this.committed = true;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Rollback transaction
|
|
676
|
+
*
|
|
677
|
+
* Delegates to adapter's rollbackTransaction which discards changes.
|
|
678
|
+
*/
|
|
679
|
+
async rollback() {
|
|
680
|
+
if (this.committed) {
|
|
681
|
+
throwTransactionAlreadyCommitted({ adapter: "json" });
|
|
682
|
+
}
|
|
683
|
+
if (this.rolledBack) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
await this.rollbackCallback();
|
|
687
|
+
this.rolledBack = true;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Create savepoint (not yet implemented for JSON adapter)
|
|
691
|
+
*/
|
|
692
|
+
async savepoint(_name) {
|
|
693
|
+
throwTransactionSavepointNotSupported({ adapter: "json" });
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Rollback to savepoint (not yet implemented for JSON adapter)
|
|
697
|
+
*/
|
|
698
|
+
async rollbackTo(_name) {
|
|
699
|
+
throwTransactionSavepointNotSupported({ adapter: "json" });
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Release savepoint (not yet implemented for JSON adapter)
|
|
703
|
+
*/
|
|
704
|
+
async release(_name) {
|
|
705
|
+
throwTransactionSavepointNotSupported({ adapter: "json" });
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// src/adapter.ts
|
|
710
|
+
import { FORJA_META_MODEL as FORJA_META_MODEL2, FORJA_META_KEY_PREFIX } from "@datrix/core";
|
|
711
|
+
|
|
712
|
+
// src/table-utils.ts
|
|
713
|
+
import {
|
|
714
|
+
DatrixAdapterError,
|
|
715
|
+
throwForeignKeyConstraint,
|
|
716
|
+
throwMigrationError,
|
|
717
|
+
throwUniqueConstraintField,
|
|
718
|
+
throwUniqueConstraintIndex
|
|
719
|
+
} from "@datrix/core";
|
|
720
|
+
import { FORJA_META_MODEL } from "@datrix/core";
|
|
721
|
+
function validateTableName(tableName) {
|
|
722
|
+
if (tableName.includes("\0")) {
|
|
723
|
+
throwMigrationError({
|
|
724
|
+
adapter: "json",
|
|
725
|
+
message: "Invalid table name: contains null byte",
|
|
726
|
+
table: tableName
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (tableName.includes("/") || tableName.includes("\\")) {
|
|
730
|
+
throwMigrationError({
|
|
731
|
+
adapter: "json",
|
|
732
|
+
message: "Invalid table name: contains path separators",
|
|
733
|
+
table: tableName
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
if (tableName.includes("..")) {
|
|
737
|
+
throwMigrationError({
|
|
738
|
+
adapter: "json",
|
|
739
|
+
message: "Invalid table name: contains parent directory reference",
|
|
740
|
+
table: tableName
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
async function createMetaTable(adapter) {
|
|
745
|
+
const metaExists = await adapter.tableExists(FORJA_META_MODEL);
|
|
746
|
+
if (metaExists) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const metaSchema = {
|
|
750
|
+
name: FORJA_META_MODEL,
|
|
751
|
+
tableName: FORJA_META_MODEL,
|
|
752
|
+
fields: {
|
|
753
|
+
id: { type: "number", autoIncrement: true },
|
|
754
|
+
key: { type: "string", required: true, unique: true, maxLength: 255 },
|
|
755
|
+
value: { type: "string", required: true },
|
|
756
|
+
createdAt: { type: "date" },
|
|
757
|
+
updatedAt: { type: "date" }
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
await adapter.createTable(metaSchema);
|
|
761
|
+
}
|
|
762
|
+
function applyDefaultValues(schema, data) {
|
|
763
|
+
if (!schema?.fields) return;
|
|
764
|
+
for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
|
|
765
|
+
if (fieldName in data) continue;
|
|
766
|
+
const defaultValue = fieldDef.default;
|
|
767
|
+
if (defaultValue !== void 0) {
|
|
768
|
+
data[fieldName] = defaultValue;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function checkUniqueConstraints(tableData, schema, newData, excludeId) {
|
|
773
|
+
if (!schema?.fields) return;
|
|
774
|
+
const existingData = tableData.data;
|
|
775
|
+
for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
|
|
776
|
+
if (!fieldDef.unique) continue;
|
|
777
|
+
const value = newData[fieldName];
|
|
778
|
+
if (value === void 0 || value === null) continue;
|
|
779
|
+
const duplicate = existingData.find(
|
|
780
|
+
(row) => row[fieldName] === value && row["id"] !== excludeId
|
|
781
|
+
);
|
|
782
|
+
if (duplicate) {
|
|
783
|
+
throwUniqueConstraintField({
|
|
784
|
+
field: fieldName,
|
|
785
|
+
value,
|
|
786
|
+
adapter: "json",
|
|
787
|
+
table: schema.tableName ?? "unknown"
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (!schema.indexes) return;
|
|
792
|
+
for (const index of schema.indexes) {
|
|
793
|
+
if (!index.unique) continue;
|
|
794
|
+
const indexValues = index.fields.map((f) => newData[f]);
|
|
795
|
+
if (indexValues.some((v) => v === void 0 || v === null)) continue;
|
|
796
|
+
const duplicate = existingData.find(
|
|
797
|
+
(row) => index.fields.every((f) => row[f] === newData[f]) && row["id"] !== excludeId
|
|
798
|
+
);
|
|
799
|
+
if (duplicate) {
|
|
800
|
+
throwUniqueConstraintIndex({
|
|
801
|
+
fields: index.fields,
|
|
802
|
+
table: schema.tableName ?? "unknown",
|
|
803
|
+
adapter: "json"
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async function checkForeignKeyConstraints(schema, data, adapter) {
|
|
809
|
+
if (!schema?.fields) return;
|
|
810
|
+
for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
|
|
811
|
+
if (fieldDef.type !== "relation") continue;
|
|
812
|
+
const relationField = fieldDef;
|
|
813
|
+
if (relationField.kind !== "belongsTo" && relationField.kind !== "hasOne") {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const foreignKey = relationField.foreignKey ?? `${fieldName}Id`;
|
|
817
|
+
const fkValue = data[foreignKey];
|
|
818
|
+
if (fkValue === void 0 || fkValue === null) continue;
|
|
819
|
+
const targetSchema = await adapter.getSchemaByModelName(
|
|
820
|
+
relationField.model
|
|
821
|
+
);
|
|
822
|
+
if (!targetSchema) continue;
|
|
823
|
+
const targetTable = targetSchema.tableName ?? relationField.model.toLowerCase();
|
|
824
|
+
const targetData = await adapter.getCachedTable(targetTable);
|
|
825
|
+
if (!targetData) continue;
|
|
826
|
+
const exists = targetData.data.some((row) => row["id"] === fkValue);
|
|
827
|
+
if (!exists) {
|
|
828
|
+
throwForeignKeyConstraint({
|
|
829
|
+
foreignKey,
|
|
830
|
+
value: fkValue,
|
|
831
|
+
targetModel: relationField.model,
|
|
832
|
+
table: schema.tableName ?? "unknown",
|
|
833
|
+
adapter: "json"
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
async function findFkDependencies(targetTable, adapter) {
|
|
839
|
+
const allTables = await adapter.getTables();
|
|
840
|
+
const deps = [];
|
|
841
|
+
for (const tableName of allTables) {
|
|
842
|
+
const schema = await adapter.getSchemaByTableName(tableName);
|
|
843
|
+
if (!schema?.fields) continue;
|
|
844
|
+
for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
|
|
845
|
+
if (fieldDef.type !== "number") continue;
|
|
846
|
+
const numField = fieldDef;
|
|
847
|
+
const ref = numField.references;
|
|
848
|
+
if (!ref || ref.table !== targetTable) continue;
|
|
849
|
+
const onDelete = ref.onDelete ?? "setNull";
|
|
850
|
+
deps.push({ tableName, fieldName, onDelete });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return deps;
|
|
854
|
+
}
|
|
855
|
+
async function applyOnDeleteActions(targetTable, idsToDelete, adapter, queryOptions) {
|
|
856
|
+
if (idsToDelete.length === 0) return;
|
|
857
|
+
const deps = await findFkDependencies(targetTable, adapter);
|
|
858
|
+
if (deps.length === 0) return;
|
|
859
|
+
for (const dep of deps) {
|
|
860
|
+
if (dep.onDelete !== "restrict") continue;
|
|
861
|
+
const tableData = await adapter.getCachedTable(dep.tableName);
|
|
862
|
+
if (!tableData) continue;
|
|
863
|
+
const hasReference = tableData.data.some(
|
|
864
|
+
(row) => idsToDelete.includes(row[dep.fieldName])
|
|
865
|
+
);
|
|
866
|
+
if (hasReference) {
|
|
867
|
+
throw new DatrixAdapterError(
|
|
868
|
+
`Cannot delete from '${targetTable}': referenced by '${dep.tableName}.${dep.fieldName}' with ON DELETE RESTRICT`,
|
|
869
|
+
{
|
|
870
|
+
adapter: "json",
|
|
871
|
+
code: "ADAPTER_FOREIGN_KEY_CONSTRAINT",
|
|
872
|
+
operation: "query",
|
|
873
|
+
context: {
|
|
874
|
+
table: targetTable,
|
|
875
|
+
referencedBy: `${dep.tableName}.${dep.fieldName}`
|
|
876
|
+
},
|
|
877
|
+
suggestion: `Remove or update referencing rows in '${dep.tableName}' before deleting from '${targetTable}'`
|
|
878
|
+
}
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
for (const dep of deps) {
|
|
883
|
+
if (dep.onDelete !== "setNull") continue;
|
|
884
|
+
await adapter.executeQueryWithOptions(
|
|
885
|
+
{
|
|
886
|
+
type: "update",
|
|
887
|
+
table: dep.tableName,
|
|
888
|
+
where: { [dep.fieldName]: { $in: idsToDelete } },
|
|
889
|
+
data: { [dep.fieldName]: null }
|
|
890
|
+
},
|
|
891
|
+
queryOptions
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
for (const dep of deps) {
|
|
895
|
+
if (dep.onDelete !== "cascade") continue;
|
|
896
|
+
const tableData = await adapter.getCachedTable(dep.tableName);
|
|
897
|
+
if (!tableData) continue;
|
|
898
|
+
const childIds = tableData.data.filter((row) => idsToDelete.includes(row[dep.fieldName])).map((row) => row["id"]);
|
|
899
|
+
if (childIds.length === 0) continue;
|
|
900
|
+
await applyOnDeleteActions(dep.tableName, childIds, adapter, queryOptions);
|
|
901
|
+
await adapter.executeQueryWithOptions(
|
|
902
|
+
{
|
|
903
|
+
type: "delete",
|
|
904
|
+
table: dep.tableName,
|
|
905
|
+
where: { id: { $in: childIds } }
|
|
906
|
+
},
|
|
907
|
+
queryOptions
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function applySelectRecursive(rows, select, populate) {
|
|
912
|
+
if (!rows || rows.length === 0) {
|
|
913
|
+
return rows;
|
|
914
|
+
}
|
|
915
|
+
let result = rows;
|
|
916
|
+
if (select && select !== "*") {
|
|
917
|
+
const fieldsToKeep = new Set(select);
|
|
918
|
+
if (populate) {
|
|
919
|
+
for (const relationName of Object.keys(populate)) {
|
|
920
|
+
fieldsToKeep.add(relationName);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
result = rows.map((row) => {
|
|
924
|
+
const projected = {};
|
|
925
|
+
for (const field of fieldsToKeep) {
|
|
926
|
+
if (field in row) {
|
|
927
|
+
projected[field] = row[field];
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return projected;
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
if (populate) {
|
|
934
|
+
for (const [relationName, options] of Object.entries(populate)) {
|
|
935
|
+
if (typeof options === "boolean") continue;
|
|
936
|
+
const nestedSelect = options === "*" ? "*" : options.select;
|
|
937
|
+
const nestedPopulate = options === "*" ? void 0 : options.populate;
|
|
938
|
+
for (const row of result) {
|
|
939
|
+
const relationValue = row[relationName];
|
|
940
|
+
if (!relationValue) continue;
|
|
941
|
+
if (Array.isArray(relationValue)) {
|
|
942
|
+
row[relationName] = applySelectRecursive(
|
|
943
|
+
relationValue,
|
|
944
|
+
nestedSelect,
|
|
945
|
+
nestedPopulate
|
|
946
|
+
);
|
|
947
|
+
} else {
|
|
948
|
+
row[relationName] = applySelectRecursive(
|
|
949
|
+
[relationValue],
|
|
950
|
+
nestedSelect,
|
|
951
|
+
nestedPopulate
|
|
952
|
+
)[0];
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return result;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/export-import/exporter.ts
|
|
961
|
+
var CHUNK_SIZE = 1e3;
|
|
962
|
+
var JsonExporter = class {
|
|
963
|
+
constructor(root, adapter) {
|
|
964
|
+
this.root = root;
|
|
965
|
+
this.adapter = adapter;
|
|
966
|
+
}
|
|
967
|
+
async export(writer) {
|
|
968
|
+
await writer.writeMeta({
|
|
969
|
+
version: 1,
|
|
970
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
971
|
+
});
|
|
972
|
+
const tables = await this.adapter.getTables();
|
|
973
|
+
for (const tableName of tables) {
|
|
974
|
+
const schema = await this.adapter.getTableSchema(tableName);
|
|
975
|
+
if (schema) {
|
|
976
|
+
await writer.writeSchema(schema);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
for (const tableName of tables) {
|
|
980
|
+
await this.exportTable(tableName, writer);
|
|
981
|
+
}
|
|
982
|
+
await writer.finalize();
|
|
983
|
+
}
|
|
984
|
+
async exportTable(tableName, writer) {
|
|
985
|
+
const fs3 = await import("fs/promises");
|
|
986
|
+
const path3 = await import("path");
|
|
987
|
+
const filePath = path3.join(this.root, `${tableName}.json`);
|
|
988
|
+
const content = await fs3.readFile(filePath, "utf-8");
|
|
989
|
+
const tableFile = JSON.parse(content);
|
|
990
|
+
const rows = tableFile.data;
|
|
991
|
+
for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
|
|
992
|
+
await writer.writeChunk(tableName, rows.slice(i, i + CHUNK_SIZE));
|
|
993
|
+
}
|
|
994
|
+
if (rows.length === 0) {
|
|
995
|
+
await writer.writeChunk(tableName, []);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// src/export-import/importer.ts
|
|
1001
|
+
var JsonImporter = class {
|
|
1002
|
+
constructor(root, adapter) {
|
|
1003
|
+
this.root = root;
|
|
1004
|
+
this.adapter = adapter;
|
|
1005
|
+
}
|
|
1006
|
+
async import(reader) {
|
|
1007
|
+
const schemas = await this.collectSchemas(reader);
|
|
1008
|
+
const existingTables = await this.adapter.getTables();
|
|
1009
|
+
for (const tableName of existingTables) {
|
|
1010
|
+
await this.adapter.dropTableWithOptions(tableName, { isImport: true });
|
|
1011
|
+
}
|
|
1012
|
+
for (const schema of schemas.values()) {
|
|
1013
|
+
await this.adapter.createTable(schema, { isImport: true });
|
|
1014
|
+
}
|
|
1015
|
+
const tables = await reader.getTables();
|
|
1016
|
+
for (const tableName of tables) {
|
|
1017
|
+
const rows = [];
|
|
1018
|
+
for await (const chunk of reader.readChunks(tableName)) {
|
|
1019
|
+
rows.push(...chunk);
|
|
1020
|
+
}
|
|
1021
|
+
await this.writeTableFile(tableName, rows);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
async collectSchemas(reader) {
|
|
1025
|
+
const schemas = /* @__PURE__ */ new Map();
|
|
1026
|
+
for await (const schema of reader.readSchemas()) {
|
|
1027
|
+
schemas.set(schema.tableName, schema);
|
|
1028
|
+
}
|
|
1029
|
+
return schemas;
|
|
1030
|
+
}
|
|
1031
|
+
async writeTableFile(tableName, rows) {
|
|
1032
|
+
const fs3 = await import("fs/promises");
|
|
1033
|
+
const path3 = await import("path");
|
|
1034
|
+
const filePath = path3.join(this.root, `${tableName}.json`);
|
|
1035
|
+
const maxId = rows.reduce((max, row) => {
|
|
1036
|
+
const id = typeof row["id"] === "number" ? row["id"] : 0;
|
|
1037
|
+
return id > max ? id : max;
|
|
1038
|
+
}, 0);
|
|
1039
|
+
const tableFile = {
|
|
1040
|
+
meta: {
|
|
1041
|
+
version: 1,
|
|
1042
|
+
lastInsertId: maxId,
|
|
1043
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1044
|
+
name: tableName
|
|
1045
|
+
},
|
|
1046
|
+
data: rows
|
|
1047
|
+
};
|
|
1048
|
+
await fs3.writeFile(filePath, JSON.stringify(tableFile, null, 2), "utf-8");
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
// src/populate.ts
|
|
1053
|
+
import {
|
|
1054
|
+
throwSchemaNotFound,
|
|
1055
|
+
throwRelationNotFound,
|
|
1056
|
+
throwInvalidRelationType,
|
|
1057
|
+
throwTargetModelNotFound
|
|
1058
|
+
} from "@datrix/core";
|
|
1059
|
+
var JsonPopulator = class {
|
|
1060
|
+
constructor(adapter) {
|
|
1061
|
+
this.adapter = adapter;
|
|
1062
|
+
}
|
|
1063
|
+
async populate(rows, query) {
|
|
1064
|
+
if (!query.populate || rows.length === 0) {
|
|
1065
|
+
return rows;
|
|
1066
|
+
}
|
|
1067
|
+
const _currentSchema = await this.adapter.getSchemaByTableName(query.table);
|
|
1068
|
+
if (!_currentSchema) {
|
|
1069
|
+
throwSchemaNotFound({ adapter: "json", modelName: query.table });
|
|
1070
|
+
}
|
|
1071
|
+
const currentSchema = _currentSchema;
|
|
1072
|
+
const currentModelName = currentSchema.name;
|
|
1073
|
+
const result = [...rows];
|
|
1074
|
+
for (const [relationName, _options] of Object.entries(query.populate)) {
|
|
1075
|
+
const relationField = currentSchema.fields[relationName];
|
|
1076
|
+
if (!relationField) {
|
|
1077
|
+
throwRelationNotFound({
|
|
1078
|
+
adapter: "json",
|
|
1079
|
+
relationName,
|
|
1080
|
+
schemaName: currentSchema.name
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
if (relationField.type !== "relation") {
|
|
1084
|
+
throwInvalidRelationType({
|
|
1085
|
+
adapter: "json",
|
|
1086
|
+
relationName,
|
|
1087
|
+
fieldType: relationField.type,
|
|
1088
|
+
schemaName: currentSchema.name
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
const relField = relationField;
|
|
1092
|
+
const targetModelName = relField.model;
|
|
1093
|
+
const foreignKey = relField.foreignKey;
|
|
1094
|
+
const kind = relField.kind;
|
|
1095
|
+
const targetSchema = await this.adapter.getSchemaByModelName(targetModelName);
|
|
1096
|
+
if (!targetSchema) {
|
|
1097
|
+
throwTargetModelNotFound({
|
|
1098
|
+
adapter: "json",
|
|
1099
|
+
targetModel: targetModelName,
|
|
1100
|
+
relationName,
|
|
1101
|
+
schemaName: currentSchema.name
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
const targetTable = targetSchema.tableName ?? targetModelName.toLowerCase();
|
|
1105
|
+
const tableData = await this.adapter.getCachedTable(targetTable);
|
|
1106
|
+
if (!tableData) continue;
|
|
1107
|
+
const relatedData = tableData.data;
|
|
1108
|
+
const options = typeof _options === "object" && _options !== null && !Array.isArray(_options) ? _options : void 0;
|
|
1109
|
+
if (kind === "belongsTo") {
|
|
1110
|
+
const ids = new Set(
|
|
1111
|
+
result.map((r) => r[foreignKey]).filter((id) => id !== null && id !== void 0)
|
|
1112
|
+
);
|
|
1113
|
+
const relatedMap = /* @__PURE__ */ new Map();
|
|
1114
|
+
if (ids.size > 0) {
|
|
1115
|
+
for (const item of relatedData) {
|
|
1116
|
+
const itemId = item["id"];
|
|
1117
|
+
if (ids.has(itemId)) {
|
|
1118
|
+
relatedMap.set(itemId, item);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
let filteredMap = relatedMap;
|
|
1123
|
+
if (options?.where) {
|
|
1124
|
+
filteredMap = /* @__PURE__ */ new Map();
|
|
1125
|
+
const filterRunner = new JsonQueryRunner(
|
|
1126
|
+
tableData,
|
|
1127
|
+
this.adapter,
|
|
1128
|
+
targetSchema
|
|
1129
|
+
);
|
|
1130
|
+
for (const [id, item] of relatedMap) {
|
|
1131
|
+
const matched = await filterRunner.filterAndSort({
|
|
1132
|
+
type: "select",
|
|
1133
|
+
table: targetTable,
|
|
1134
|
+
where: options.where,
|
|
1135
|
+
select: "*"
|
|
1136
|
+
});
|
|
1137
|
+
if (matched.some((r) => r["id"] === id)) {
|
|
1138
|
+
filteredMap.set(id, item);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const row of result) {
|
|
1143
|
+
const fkValue = row[foreignKey];
|
|
1144
|
+
if (fkValue !== null && fkValue !== void 0) {
|
|
1145
|
+
row[relationName] = filteredMap.get(fkValue) ?? null;
|
|
1146
|
+
} else {
|
|
1147
|
+
row[relationName] = null;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
} else if (kind === "hasMany" || kind === "hasOne") {
|
|
1151
|
+
const sourceIds = new Set(
|
|
1152
|
+
result.map((r) => r["id"]).filter((id) => id !== null && id !== void 0)
|
|
1153
|
+
);
|
|
1154
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1155
|
+
for (const item of relatedData) {
|
|
1156
|
+
const fkValue = item[foreignKey];
|
|
1157
|
+
if (fkValue !== null && fkValue !== void 0 && sourceIds.has(fkValue)) {
|
|
1158
|
+
const group = grouped.get(fkValue) ?? [];
|
|
1159
|
+
group.push(item);
|
|
1160
|
+
grouped.set(fkValue, group);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
const hasSortOrFilter = options?.where || options?.orderBy || options?.limit !== void 0 || options?.offset !== void 0;
|
|
1164
|
+
for (const row of result) {
|
|
1165
|
+
const rowId = row["id"];
|
|
1166
|
+
let group = grouped.get(rowId) ?? [];
|
|
1167
|
+
if (kind === "hasOne") {
|
|
1168
|
+
row[relationName] = group[0] ?? null;
|
|
1169
|
+
} else {
|
|
1170
|
+
if (hasSortOrFilter && group.length > 0) {
|
|
1171
|
+
const groupTable = { ...tableData, data: group };
|
|
1172
|
+
const groupRunner = new JsonQueryRunner(
|
|
1173
|
+
groupTable,
|
|
1174
|
+
this.adapter,
|
|
1175
|
+
targetSchema
|
|
1176
|
+
);
|
|
1177
|
+
group = await groupRunner.filterAndSort({
|
|
1178
|
+
type: "select",
|
|
1179
|
+
table: targetTable,
|
|
1180
|
+
where: options?.where,
|
|
1181
|
+
orderBy: options?.orderBy,
|
|
1182
|
+
limit: options?.limit,
|
|
1183
|
+
offset: options?.offset,
|
|
1184
|
+
select: "*"
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
row[relationName] = group;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
} else if (kind === "manyToMany") {
|
|
1191
|
+
const junctionTableName = relField.through;
|
|
1192
|
+
const sourceFK = `${currentModelName}Id`;
|
|
1193
|
+
const targetFK = `${targetModelName}Id`;
|
|
1194
|
+
const junctionData = await this.adapter.getCachedTable(junctionTableName);
|
|
1195
|
+
if (!junctionData) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
`Junction table '${junctionTableName}' not found for manyToMany relation '${relationName}' in schema '${currentSchema.name}'`
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
const sourceIds = result.map((r) => r["id"]).filter((id) => id !== null && id !== void 0);
|
|
1201
|
+
if (sourceIds.length === 0) continue;
|
|
1202
|
+
const junctionRunner = new JsonQueryRunner(junctionData, this.adapter);
|
|
1203
|
+
const relevantJunctions = await junctionRunner.run({
|
|
1204
|
+
type: "select",
|
|
1205
|
+
table: junctionTableName,
|
|
1206
|
+
where: { [sourceFK]: { $in: sourceIds } },
|
|
1207
|
+
select: "*"
|
|
1208
|
+
});
|
|
1209
|
+
const mapping = /* @__PURE__ */ new Map();
|
|
1210
|
+
for (const junction of relevantJunctions) {
|
|
1211
|
+
const srcId = junction[sourceFK];
|
|
1212
|
+
const tgtId = junction[targetFK];
|
|
1213
|
+
const normalizedSrcId = typeof srcId === "string" ? Number(srcId) : srcId;
|
|
1214
|
+
const normalizedTgtId = typeof tgtId === "string" ? Number(tgtId) : tgtId;
|
|
1215
|
+
const existing = mapping.get(normalizedSrcId) ?? [];
|
|
1216
|
+
existing.push(normalizedTgtId);
|
|
1217
|
+
mapping.set(normalizedSrcId, existing);
|
|
1218
|
+
}
|
|
1219
|
+
const allTargetIds = /* @__PURE__ */ new Set();
|
|
1220
|
+
for (const ids of mapping.values()) {
|
|
1221
|
+
ids.forEach((id) => allTargetIds.add(id));
|
|
1222
|
+
}
|
|
1223
|
+
const targetDataForRunner = await this.adapter.getCachedTable(targetTable);
|
|
1224
|
+
if (!targetDataForRunner) continue;
|
|
1225
|
+
const idFilter = { id: { $in: Array.from(allTargetIds) } };
|
|
1226
|
+
const userWhere = options?.where;
|
|
1227
|
+
const mergedWhere = userWhere ? { $and: [idFilter, userWhere] } : idFilter;
|
|
1228
|
+
const targetRunner = new JsonQueryRunner(
|
|
1229
|
+
targetDataForRunner,
|
|
1230
|
+
this.adapter,
|
|
1231
|
+
targetSchema
|
|
1232
|
+
);
|
|
1233
|
+
const targetRecords = await targetRunner.run({
|
|
1234
|
+
type: "select",
|
|
1235
|
+
table: targetTable,
|
|
1236
|
+
where: mergedWhere,
|
|
1237
|
+
orderBy: options?.orderBy,
|
|
1238
|
+
select: "*"
|
|
1239
|
+
});
|
|
1240
|
+
for (const row of result) {
|
|
1241
|
+
const rowId = row["id"];
|
|
1242
|
+
const normalizedRowId = typeof rowId === "string" ? Number(rowId) : rowId;
|
|
1243
|
+
const targetIds = mapping.get(normalizedRowId) ?? [];
|
|
1244
|
+
let relatedRecords = targetRecords.filter((r) => {
|
|
1245
|
+
const rID = r["id"];
|
|
1246
|
+
const normalizedRID = typeof rID === "string" ? Number(rID) : rID;
|
|
1247
|
+
return targetIds.includes(normalizedRID);
|
|
1248
|
+
});
|
|
1249
|
+
const offset = options?.offset ?? 0;
|
|
1250
|
+
if (options?.limit !== void 0) {
|
|
1251
|
+
relatedRecords = relatedRecords.slice(
|
|
1252
|
+
offset,
|
|
1253
|
+
offset + options.limit
|
|
1254
|
+
);
|
|
1255
|
+
} else if (offset > 0) {
|
|
1256
|
+
relatedRecords = relatedRecords.slice(offset);
|
|
1257
|
+
}
|
|
1258
|
+
row[relationName] = relatedRecords;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (typeof _options === "object" && _options !== null && _options.populate) {
|
|
1262
|
+
const nextRows = [];
|
|
1263
|
+
for (const row of result) {
|
|
1264
|
+
const val = row[relationName];
|
|
1265
|
+
if (!val) continue;
|
|
1266
|
+
if (Array.isArray(val)) {
|
|
1267
|
+
nextRows.push(...val);
|
|
1268
|
+
} else {
|
|
1269
|
+
nextRows.push(val);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
if (nextRows.length > 0) {
|
|
1273
|
+
await this.populate(nextRows, {
|
|
1274
|
+
type: "select",
|
|
1275
|
+
table: targetTable,
|
|
1276
|
+
populate: _options.populate,
|
|
1277
|
+
select: "*"
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return result;
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
// src/query-handlers.ts
|
|
1287
|
+
import { throwQueryMissingData } from "@datrix/core";
|
|
1288
|
+
async function handleSelect(ctx) {
|
|
1289
|
+
const { runner, query, adapter } = ctx;
|
|
1290
|
+
let rows;
|
|
1291
|
+
if (query.populate) {
|
|
1292
|
+
rows = await runner.filterAndSort(query);
|
|
1293
|
+
const populator = new JsonPopulator(adapter);
|
|
1294
|
+
rows = await populator.populate(rows, query);
|
|
1295
|
+
rows = applySelectRecursive(rows, query.select, query.populate);
|
|
1296
|
+
} else {
|
|
1297
|
+
rows = await runner.run(query);
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
rows,
|
|
1301
|
+
metadata: { rowCount: rows.length, affectedRows: 0 },
|
|
1302
|
+
shouldWrite: false
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
async function handleCount(ctx) {
|
|
1306
|
+
const { runner, query } = ctx;
|
|
1307
|
+
const rows = await runner.run(query);
|
|
1308
|
+
return {
|
|
1309
|
+
rows: [],
|
|
1310
|
+
metadata: { rowCount: 0, affectedRows: 0, count: rows.length },
|
|
1311
|
+
shouldWrite: false,
|
|
1312
|
+
earlyReturn: true
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
async function handleInsert(ctx) {
|
|
1316
|
+
const { runner, query } = ctx;
|
|
1317
|
+
const tableData = runner.tableData;
|
|
1318
|
+
const tableSchema = runner.tableSchema;
|
|
1319
|
+
const adapter = runner.adapterRef;
|
|
1320
|
+
if (!query.data || !Array.isArray(query.data)) {
|
|
1321
|
+
throwQueryMissingData({
|
|
1322
|
+
queryType: "insert",
|
|
1323
|
+
table: query.table,
|
|
1324
|
+
adapter: "json"
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
const insertedIds = [];
|
|
1328
|
+
const isJunctionTable = tableSchema?._isJunctionTable === true;
|
|
1329
|
+
for (const item of query.data) {
|
|
1330
|
+
const newItem = { ...item };
|
|
1331
|
+
if (isJunctionTable) {
|
|
1332
|
+
const alreadyExists = tableData.data.some(
|
|
1333
|
+
(row) => Object.keys(newItem).every(
|
|
1334
|
+
(key) => key === "id" || row[key] === newItem[key]
|
|
1335
|
+
)
|
|
1336
|
+
);
|
|
1337
|
+
if (alreadyExists) continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (!newItem["id"]) {
|
|
1340
|
+
tableData.meta.lastInsertId = (tableData.meta.lastInsertId ?? 0) + 1;
|
|
1341
|
+
newItem["id"] = tableData.meta.lastInsertId;
|
|
1342
|
+
} else {
|
|
1343
|
+
const manualId = Number(newItem["id"]);
|
|
1344
|
+
if (!isNaN(manualId) && manualId > (tableData.meta.lastInsertId ?? 0)) {
|
|
1345
|
+
tableData.meta.lastInsertId = manualId;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
applyDefaultValues(tableSchema, newItem);
|
|
1349
|
+
await checkForeignKeyConstraints(tableSchema, newItem, adapter);
|
|
1350
|
+
checkUniqueConstraints(tableData, tableSchema, newItem);
|
|
1351
|
+
tableData.data.push(newItem);
|
|
1352
|
+
insertedIds.push(newItem["id"]);
|
|
1353
|
+
}
|
|
1354
|
+
const rows = insertedIds.map((id) => ({ id }));
|
|
1355
|
+
return {
|
|
1356
|
+
rows,
|
|
1357
|
+
metadata: {
|
|
1358
|
+
rowCount: insertedIds.length,
|
|
1359
|
+
affectedRows: insertedIds.length,
|
|
1360
|
+
insertIds: insertedIds
|
|
1361
|
+
},
|
|
1362
|
+
shouldWrite: true
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
async function handleUpdate(ctx) {
|
|
1366
|
+
const { runner, query } = ctx;
|
|
1367
|
+
const tableData = runner.tableData;
|
|
1368
|
+
const tableSchema = runner.tableSchema;
|
|
1369
|
+
const adapter = runner.adapterRef;
|
|
1370
|
+
if (!query.data) {
|
|
1371
|
+
throwQueryMissingData({
|
|
1372
|
+
queryType: "update",
|
|
1373
|
+
table: query.table,
|
|
1374
|
+
adapter: "json"
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
const updateQuery = {
|
|
1378
|
+
...query,
|
|
1379
|
+
limit: void 0,
|
|
1380
|
+
offset: void 0,
|
|
1381
|
+
orderBy: void 0
|
|
1382
|
+
};
|
|
1383
|
+
const rowsToUpdate = await runner.filterAndSort(updateQuery);
|
|
1384
|
+
for (const row of rowsToUpdate) {
|
|
1385
|
+
const updatedData = { ...row, ...query.data };
|
|
1386
|
+
await checkForeignKeyConstraints(tableSchema, updatedData, adapter);
|
|
1387
|
+
checkUniqueConstraints(
|
|
1388
|
+
tableData,
|
|
1389
|
+
tableSchema,
|
|
1390
|
+
updatedData,
|
|
1391
|
+
row["id"]
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
for (const row of rowsToUpdate) {
|
|
1395
|
+
Object.assign(row, query.data);
|
|
1396
|
+
}
|
|
1397
|
+
const updatedIds = rowsToUpdate.map((r) => r["id"]);
|
|
1398
|
+
const rows = updatedIds.map((id) => ({ id }));
|
|
1399
|
+
return {
|
|
1400
|
+
rows,
|
|
1401
|
+
metadata: { rowCount: updatedIds.length, affectedRows: updatedIds.length },
|
|
1402
|
+
shouldWrite: true
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
async function handleDelete(ctx) {
|
|
1406
|
+
const { runner, query, adapter, queryOptions } = ctx;
|
|
1407
|
+
const tableData = runner.tableData;
|
|
1408
|
+
const deleteQuery = {
|
|
1409
|
+
...query,
|
|
1410
|
+
limit: void 0,
|
|
1411
|
+
offset: void 0,
|
|
1412
|
+
orderBy: void 0
|
|
1413
|
+
};
|
|
1414
|
+
const rowsToDelete = await runner.filterAndSort(deleteQuery);
|
|
1415
|
+
const idsToDelete = rowsToDelete.map((r) => r.id);
|
|
1416
|
+
await applyOnDeleteActions(query.table, idsToDelete, adapter, queryOptions);
|
|
1417
|
+
const idsSet = new Set(idsToDelete);
|
|
1418
|
+
const originalLength = tableData.data.length;
|
|
1419
|
+
tableData.data = tableData.data.filter((d) => !idsSet.has(d["id"]));
|
|
1420
|
+
const deletedIds = rowsToDelete.map((r) => r["id"]);
|
|
1421
|
+
const rows = deletedIds.map((id) => ({ id }));
|
|
1422
|
+
return {
|
|
1423
|
+
rows,
|
|
1424
|
+
metadata: {
|
|
1425
|
+
rowCount: deletedIds.length,
|
|
1426
|
+
affectedRows: originalLength - tableData.data.length
|
|
1427
|
+
},
|
|
1428
|
+
shouldWrite: true
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// src/adapter.ts
|
|
1433
|
+
var JsonAdapter = class {
|
|
1434
|
+
name = "json";
|
|
1435
|
+
config;
|
|
1436
|
+
state = "disconnected";
|
|
1437
|
+
cache = /* @__PURE__ */ new Map();
|
|
1438
|
+
lock;
|
|
1439
|
+
cacheEnabled;
|
|
1440
|
+
readLockEnabled;
|
|
1441
|
+
/**
|
|
1442
|
+
* Active transaction cache reference
|
|
1443
|
+
* When a transaction is active, all reads/writes go through this cache first.
|
|
1444
|
+
* Set by beginTransaction, cleared by commit/rollback.
|
|
1445
|
+
*/
|
|
1446
|
+
activeTransactionCache = null;
|
|
1447
|
+
/**
|
|
1448
|
+
* Track modified tables during transaction for commit
|
|
1449
|
+
*/
|
|
1450
|
+
activeTransactionModifiedTables = null;
|
|
1451
|
+
/**
|
|
1452
|
+
* Tombstone set for tables deleted during transaction.
|
|
1453
|
+
* Prevents fallback to main cache or disk for dropped tables.
|
|
1454
|
+
*/
|
|
1455
|
+
activeTransactionDeletedTables = null;
|
|
1456
|
+
constructor(config) {
|
|
1457
|
+
this.config = config;
|
|
1458
|
+
this.lock = new SimpleLock(
|
|
1459
|
+
config.root,
|
|
1460
|
+
config.lockTimeout,
|
|
1461
|
+
config.staleTimeout
|
|
1462
|
+
);
|
|
1463
|
+
this.cacheEnabled = config.cache !== false;
|
|
1464
|
+
this.readLockEnabled = config.readLock === true;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Connect involves ensuring the root directory exists
|
|
1468
|
+
*/
|
|
1469
|
+
async connect() {
|
|
1470
|
+
if (this.state === "connected") {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
this.state = "connecting";
|
|
1474
|
+
try {
|
|
1475
|
+
await fs2.mkdir(this.config.root, { recursive: true });
|
|
1476
|
+
this.state = "connected";
|
|
1477
|
+
if (this.config.standalone) {
|
|
1478
|
+
try {
|
|
1479
|
+
await createMetaTable(this);
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
this.state = "error";
|
|
1482
|
+
throw error;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
this.state = "error";
|
|
1487
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1488
|
+
throwConnectionError({
|
|
1489
|
+
adapter: "json",
|
|
1490
|
+
message: `Failed to access root directory: ${message}`,
|
|
1491
|
+
cause: error instanceof Error ? error : new Error(String(error))
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async disconnect() {
|
|
1496
|
+
this.state = "disconnected";
|
|
1497
|
+
}
|
|
1498
|
+
isConnected() {
|
|
1499
|
+
return this.state === "connected";
|
|
1500
|
+
}
|
|
1501
|
+
getConnectionState() {
|
|
1502
|
+
return this.state;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Helper to get file path for a table
|
|
1506
|
+
*/
|
|
1507
|
+
getTablePath(tableName) {
|
|
1508
|
+
return path2.join(this.config.root, `${tableName}.json`);
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Read table with cache support
|
|
1512
|
+
*
|
|
1513
|
+
* Cache lookup order:
|
|
1514
|
+
* 1. Check tombstone (if transaction active and table was dropped)
|
|
1515
|
+
* 2. Transaction cache (if active)
|
|
1516
|
+
* 3. Main cache (with mtime validation)
|
|
1517
|
+
* 4. Disk
|
|
1518
|
+
*
|
|
1519
|
+
* When transaction is active, new reads are cached in transaction cache.
|
|
1520
|
+
* This ensures isolation - transaction sees its own writes.
|
|
1521
|
+
*/
|
|
1522
|
+
async readTable(tableName) {
|
|
1523
|
+
const filePath = this.getTablePath(tableName);
|
|
1524
|
+
if (this.activeTransactionDeletedTables?.has(tableName)) {
|
|
1525
|
+
throw new Error(`Table '${tableName}' does not exist`);
|
|
1526
|
+
}
|
|
1527
|
+
if (this.activeTransactionCache) {
|
|
1528
|
+
const txCached = this.activeTransactionCache.get(tableName);
|
|
1529
|
+
if (txCached) {
|
|
1530
|
+
return txCached.data;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (this.cacheEnabled) {
|
|
1534
|
+
const stat = await fs2.stat(filePath);
|
|
1535
|
+
const mtime = stat.mtimeMs;
|
|
1536
|
+
const cached = this.cache.get(tableName);
|
|
1537
|
+
if (cached && cached.mtime === mtime) {
|
|
1538
|
+
if (this.activeTransactionCache) {
|
|
1539
|
+
const txData = JSON.parse(JSON.stringify(cached.data));
|
|
1540
|
+
this.activeTransactionCache.set(tableName, { data: txData, mtime });
|
|
1541
|
+
return txData;
|
|
1542
|
+
}
|
|
1543
|
+
return cached.data;
|
|
1544
|
+
}
|
|
1545
|
+
const content2 = await fs2.readFile(filePath, "utf-8");
|
|
1546
|
+
const data = JSON.parse(content2);
|
|
1547
|
+
if (this.activeTransactionCache) {
|
|
1548
|
+
this.activeTransactionCache.set(tableName, { data, mtime });
|
|
1549
|
+
} else {
|
|
1550
|
+
this.cache.set(tableName, { data, mtime });
|
|
1551
|
+
}
|
|
1552
|
+
return data;
|
|
1553
|
+
}
|
|
1554
|
+
const content = await fs2.readFile(filePath, "utf-8");
|
|
1555
|
+
return JSON.parse(content);
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Get cached table data (for external use like Populate)
|
|
1559
|
+
*/
|
|
1560
|
+
async getCachedTable(tableName) {
|
|
1561
|
+
try {
|
|
1562
|
+
return await this.readTable(tableName);
|
|
1563
|
+
} catch {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Get schema directly from table file (cache-aware)
|
|
1569
|
+
* This is faster than going through Datrix registry and ensures consistency
|
|
1570
|
+
*
|
|
1571
|
+
* @param tableName - Table name (e.g., "users")
|
|
1572
|
+
* @returns Schema definition or null if not found
|
|
1573
|
+
*/
|
|
1574
|
+
async getSchemaByTableName(tableName) {
|
|
1575
|
+
try {
|
|
1576
|
+
return await this.readTableSchema(tableName);
|
|
1577
|
+
} catch {
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Get schema by model name
|
|
1583
|
+
* Requires scanning all tables to find matching schema.name
|
|
1584
|
+
* Prefer getSchemaByTableName when table name is known (faster)
|
|
1585
|
+
*
|
|
1586
|
+
* @param modelName - Model name from schema (e.g., "User")
|
|
1587
|
+
* @returns Schema definition or null if not found
|
|
1588
|
+
*/
|
|
1589
|
+
async getSchemaByModelName(modelName) {
|
|
1590
|
+
try {
|
|
1591
|
+
const tablesResult = await this.getTables();
|
|
1592
|
+
for (const tableName of tablesResult) {
|
|
1593
|
+
const schema = await this.getSchemaByTableName(tableName);
|
|
1594
|
+
if (schema?.name === modelName) {
|
|
1595
|
+
return schema;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return null;
|
|
1599
|
+
} catch {
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Find table name by schema model name
|
|
1605
|
+
*
|
|
1606
|
+
* @param modelName - Model name (e.g., "User")
|
|
1607
|
+
* @returns Table name or null if not found
|
|
1608
|
+
*/
|
|
1609
|
+
async findTableNameByModelName(modelName) {
|
|
1610
|
+
const schema = await this.getSchemaByModelName(modelName);
|
|
1611
|
+
return schema?.tableName ?? null;
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Read schema for a table from _datrix metadata table.
|
|
1615
|
+
* Transaction-aware: reads from tx cache when inside a transaction.
|
|
1616
|
+
*
|
|
1617
|
+
* @param tableName - Physical table name (e.g. "users")
|
|
1618
|
+
*/
|
|
1619
|
+
async readTableSchema(tableName) {
|
|
1620
|
+
const metaFile = await this.readTable(FORJA_META_MODEL2);
|
|
1621
|
+
const metaKey = `${FORJA_META_KEY_PREFIX}${tableName}`;
|
|
1622
|
+
const row = metaFile.data.find(
|
|
1623
|
+
(r) => r["key"] === metaKey
|
|
1624
|
+
);
|
|
1625
|
+
if (!row) {
|
|
1626
|
+
throw new Error(`Schema for '${tableName}' not found in _datrix`);
|
|
1627
|
+
}
|
|
1628
|
+
return JSON.parse(
|
|
1629
|
+
row["value"]
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Upsert schema into _datrix metadata table
|
|
1634
|
+
*/
|
|
1635
|
+
async upsertSchemaMeta(schema, skipWrite) {
|
|
1636
|
+
const metaKey = `${FORJA_META_KEY_PREFIX}${schema.tableName ?? schema.name}`;
|
|
1637
|
+
const metaValue = JSON.stringify(schema);
|
|
1638
|
+
const metaFile = await this.readTable(FORJA_META_MODEL2);
|
|
1639
|
+
const existingIndex = metaFile.data.findIndex(
|
|
1640
|
+
(r) => r["key"] === metaKey
|
|
1641
|
+
);
|
|
1642
|
+
if (existingIndex >= 0) {
|
|
1643
|
+
metaFile.data[existingIndex]["value"] = metaValue;
|
|
1644
|
+
} else {
|
|
1645
|
+
const lastInsertId = (metaFile.meta.lastInsertId ?? 0) + 1;
|
|
1646
|
+
metaFile.meta.lastInsertId = lastInsertId;
|
|
1647
|
+
metaFile.data.push({
|
|
1648
|
+
id: lastInsertId,
|
|
1649
|
+
key: metaKey,
|
|
1650
|
+
value: metaValue
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
metaFile.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1654
|
+
if (skipWrite) {
|
|
1655
|
+
this.activeTransactionCache.set(FORJA_META_MODEL2, {
|
|
1656
|
+
data: metaFile,
|
|
1657
|
+
mtime: Date.now()
|
|
1658
|
+
});
|
|
1659
|
+
this.activeTransactionModifiedTables.add(FORJA_META_MODEL2);
|
|
1660
|
+
} else {
|
|
1661
|
+
const filePath = this.getTablePath(FORJA_META_MODEL2);
|
|
1662
|
+
await fs2.writeFile(filePath, JSON.stringify(metaFile, null, 2), "utf-8");
|
|
1663
|
+
await this.updateCache(FORJA_META_MODEL2, metaFile);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Apply AlterOperations to schema in _datrix and write back
|
|
1668
|
+
*/
|
|
1669
|
+
async applyOperationsToMetaSchema(tableName, operations, skipWrite) {
|
|
1670
|
+
const schema = await this.readTableSchema(tableName);
|
|
1671
|
+
const fields = { ...schema.fields };
|
|
1672
|
+
for (const op of operations) {
|
|
1673
|
+
switch (op.type) {
|
|
1674
|
+
case "addColumn":
|
|
1675
|
+
fields[op.column] = op.definition;
|
|
1676
|
+
break;
|
|
1677
|
+
case "dropColumn":
|
|
1678
|
+
delete fields[op.column];
|
|
1679
|
+
break;
|
|
1680
|
+
case "modifyColumn":
|
|
1681
|
+
fields[op.column] = op.newDefinition;
|
|
1682
|
+
break;
|
|
1683
|
+
case "renameColumn": {
|
|
1684
|
+
const fieldDef = fields[op.from];
|
|
1685
|
+
if (fieldDef !== void 0) {
|
|
1686
|
+
fields[op.to] = fieldDef;
|
|
1687
|
+
delete fields[op.from];
|
|
1688
|
+
}
|
|
1689
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
1690
|
+
if (def.type === "relation" && def.foreignKey === op.from) {
|
|
1691
|
+
fields[key] = { ...def, foreignKey: op.to };
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
break;
|
|
1695
|
+
}
|
|
1696
|
+
case "addMetaField":
|
|
1697
|
+
if (fields[op.field] !== void 0) {
|
|
1698
|
+
throwMetaFieldAlreadyExists({
|
|
1699
|
+
adapter: "json",
|
|
1700
|
+
field: op.field,
|
|
1701
|
+
table: tableName
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
fields[op.field] = op.definition;
|
|
1705
|
+
break;
|
|
1706
|
+
case "dropMetaField":
|
|
1707
|
+
if (fields[op.field] === void 0) {
|
|
1708
|
+
throwMetaFieldNotFound({
|
|
1709
|
+
adapter: "json",
|
|
1710
|
+
field: op.field,
|
|
1711
|
+
table: tableName
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
delete fields[op.field];
|
|
1715
|
+
break;
|
|
1716
|
+
case "modifyMetaField":
|
|
1717
|
+
if (fields[op.field] === void 0) {
|
|
1718
|
+
throwMetaFieldNotFound({
|
|
1719
|
+
adapter: "json",
|
|
1720
|
+
field: op.field,
|
|
1721
|
+
table: tableName
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
fields[op.field] = op.newDefinition;
|
|
1725
|
+
break;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
const updatedSchema = { ...schema, fields };
|
|
1729
|
+
await this.upsertSchemaMeta(updatedSchema, skipWrite);
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Invalidate cache for a specific table
|
|
1733
|
+
*/
|
|
1734
|
+
invalidateCache(tableName) {
|
|
1735
|
+
this.cache.delete(tableName);
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Update cache after write operation
|
|
1739
|
+
*/
|
|
1740
|
+
async updateCache(tableName, data) {
|
|
1741
|
+
if (!this.cacheEnabled) return;
|
|
1742
|
+
const filePath = this.getTablePath(tableName);
|
|
1743
|
+
try {
|
|
1744
|
+
const stat = await fs2.stat(filePath);
|
|
1745
|
+
this.cache.set(tableName, { data, mtime: stat.mtimeMs });
|
|
1746
|
+
} catch {
|
|
1747
|
+
this.invalidateCache(tableName);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
async exportData(writer) {
|
|
1751
|
+
await new JsonExporter(this.config.root, this).export(writer);
|
|
1752
|
+
}
|
|
1753
|
+
async importData(reader) {
|
|
1754
|
+
await new JsonImporter(this.config.root, this).import(reader);
|
|
1755
|
+
}
|
|
1756
|
+
async createTable(schema, options) {
|
|
1757
|
+
return this.createTableWithOptions(schema, void 0, options?.isImport);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Create table with options (for transaction support)
|
|
1761
|
+
*/
|
|
1762
|
+
async createTableWithOptions(schema, options, isImport) {
|
|
1763
|
+
const skipWrite = options?.skipWrite ?? false;
|
|
1764
|
+
if (this.config.standalone && !("id" in schema.fields)) {
|
|
1765
|
+
schema = {
|
|
1766
|
+
...schema,
|
|
1767
|
+
fields: {
|
|
1768
|
+
id: { type: "number", autoIncrement: true },
|
|
1769
|
+
...schema.fields
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
if (!this.isConnected()) {
|
|
1774
|
+
throwNotConnected({ adapter: "json" });
|
|
1775
|
+
}
|
|
1776
|
+
const tableName = schema.tableName;
|
|
1777
|
+
validateTableName(tableName);
|
|
1778
|
+
if (this.activeTransactionCache?.has(tableName)) {
|
|
1779
|
+
throwMigrationError2({
|
|
1780
|
+
adapter: "json",
|
|
1781
|
+
message: `Table '${schema.name}' already exists`,
|
|
1782
|
+
table: tableName
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
const wasDeleted = this.activeTransactionDeletedTables?.has(tableName);
|
|
1786
|
+
if (wasDeleted) {
|
|
1787
|
+
this.activeTransactionDeletedTables.delete(tableName);
|
|
1788
|
+
}
|
|
1789
|
+
if (!wasDeleted) {
|
|
1790
|
+
const filePath = this.getTablePath(tableName);
|
|
1791
|
+
try {
|
|
1792
|
+
await fs2.access(filePath);
|
|
1793
|
+
throwMigrationError2({
|
|
1794
|
+
adapter: "json",
|
|
1795
|
+
message: `Table '${schema.name}' already exists`
|
|
1796
|
+
});
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
if (err instanceof DatrixAdapterError2) throw err;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const initialContent = {
|
|
1802
|
+
meta: {
|
|
1803
|
+
version: 1,
|
|
1804
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1805
|
+
name: schema.name
|
|
1806
|
+
},
|
|
1807
|
+
data: []
|
|
1808
|
+
};
|
|
1809
|
+
if (skipWrite) {
|
|
1810
|
+
this.activeTransactionCache.set(tableName, {
|
|
1811
|
+
data: initialContent,
|
|
1812
|
+
mtime: Date.now()
|
|
1813
|
+
});
|
|
1814
|
+
this.activeTransactionModifiedTables.add(tableName);
|
|
1815
|
+
} else {
|
|
1816
|
+
const filePath = this.getTablePath(tableName);
|
|
1817
|
+
await fs2.writeFile(
|
|
1818
|
+
filePath,
|
|
1819
|
+
JSON.stringify(initialContent, null, 2),
|
|
1820
|
+
"utf-8"
|
|
1821
|
+
);
|
|
1822
|
+
await this.updateCache(tableName, initialContent);
|
|
1823
|
+
}
|
|
1824
|
+
if (!isImport) {
|
|
1825
|
+
if (schema.name !== FORJA_META_MODEL2) {
|
|
1826
|
+
const metaExists = await this.tableExists(FORJA_META_MODEL2);
|
|
1827
|
+
if (!metaExists) {
|
|
1828
|
+
throwMigrationError2({
|
|
1829
|
+
adapter: "json",
|
|
1830
|
+
message: `Cannot create table '${schema.name}': '${FORJA_META_MODEL2}' table does not exist yet. Create '${FORJA_META_MODEL2}' first.`
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
await this.upsertSchemaMeta(schema, skipWrite);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
async dropTable(tableName) {
|
|
1838
|
+
return this.dropTableWithOptions(tableName);
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Drop table with options (for transaction support)
|
|
1842
|
+
*/
|
|
1843
|
+
async dropTableWithOptions(tableName, options) {
|
|
1844
|
+
const skipWrite = options?.skipWrite ?? false;
|
|
1845
|
+
const isImport = options?.isImport ?? false;
|
|
1846
|
+
if (!this.isConnected()) {
|
|
1847
|
+
throwNotConnected({ adapter: "json" });
|
|
1848
|
+
}
|
|
1849
|
+
if (this.activeTransactionDeletedTables?.has(tableName)) {
|
|
1850
|
+
throwMigrationError2({
|
|
1851
|
+
adapter: "json",
|
|
1852
|
+
message: `Table '${tableName}' does not exist`
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
const existsInTxCache = this.activeTransactionCache?.has(tableName);
|
|
1856
|
+
const existsInMainCache = this.cache.has(tableName);
|
|
1857
|
+
let existsOnDisk = false;
|
|
1858
|
+
if (!existsInTxCache && !existsInMainCache) {
|
|
1859
|
+
const filePath = this.getTablePath(tableName);
|
|
1860
|
+
try {
|
|
1861
|
+
await fs2.access(filePath);
|
|
1862
|
+
existsOnDisk = true;
|
|
1863
|
+
} catch {
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
if (!existsInTxCache && !existsInMainCache && !existsOnDisk) {
|
|
1867
|
+
throwMigrationError2({
|
|
1868
|
+
adapter: "json",
|
|
1869
|
+
message: `Table '${tableName}' does not exist`
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
if (skipWrite) {
|
|
1873
|
+
this.activeTransactionDeletedTables.add(tableName);
|
|
1874
|
+
this.activeTransactionCache.delete(tableName);
|
|
1875
|
+
} else {
|
|
1876
|
+
const filePath = this.getTablePath(tableName);
|
|
1877
|
+
await fs2.unlink(filePath);
|
|
1878
|
+
this.invalidateCache(tableName);
|
|
1879
|
+
}
|
|
1880
|
+
if (!isImport && tableName !== FORJA_META_MODEL2) {
|
|
1881
|
+
const metaKey = `${FORJA_META_KEY_PREFIX}${tableName}`;
|
|
1882
|
+
const metaFile = await this.readTable(FORJA_META_MODEL2);
|
|
1883
|
+
metaFile.data = metaFile.data.filter(
|
|
1884
|
+
(r) => r["key"] !== metaKey
|
|
1885
|
+
);
|
|
1886
|
+
metaFile.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1887
|
+
if (skipWrite) {
|
|
1888
|
+
this.activeTransactionCache.set(FORJA_META_MODEL2, {
|
|
1889
|
+
data: metaFile,
|
|
1890
|
+
mtime: Date.now()
|
|
1891
|
+
});
|
|
1892
|
+
this.activeTransactionModifiedTables.add(FORJA_META_MODEL2);
|
|
1893
|
+
} else {
|
|
1894
|
+
const filePath = this.getTablePath(FORJA_META_MODEL2);
|
|
1895
|
+
await fs2.writeFile(
|
|
1896
|
+
filePath,
|
|
1897
|
+
JSON.stringify(metaFile, null, 2),
|
|
1898
|
+
"utf-8"
|
|
1899
|
+
);
|
|
1900
|
+
await this.updateCache(FORJA_META_MODEL2, metaFile);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Execute query (public interface)
|
|
1906
|
+
*
|
|
1907
|
+
* This is the standard DatabaseAdapter interface method.
|
|
1908
|
+
* Internally calls executeQueryWithOptions with default options.
|
|
1909
|
+
*/
|
|
1910
|
+
async executeQuery(query) {
|
|
1911
|
+
return this.executeQueryWithOptions(query);
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Execute query with options (for transaction support)
|
|
1915
|
+
*
|
|
1916
|
+
* @param query - Query to execute
|
|
1917
|
+
* @param options - Execution options
|
|
1918
|
+
* @param options.skipLock - Skip lock acquisition (transaction already holds lock)
|
|
1919
|
+
* @param options.skipWrite - Skip writing to disk (transaction will write on commit)
|
|
1920
|
+
*/
|
|
1921
|
+
async executeQueryWithOptions(query, options) {
|
|
1922
|
+
const skipLock = options?.skipLock ?? false;
|
|
1923
|
+
const skipWrite = options?.skipWrite ?? false;
|
|
1924
|
+
validateQueryObject(query);
|
|
1925
|
+
if (!this.isConnected()) {
|
|
1926
|
+
throwNotConnected({ adapter: "json" });
|
|
1927
|
+
}
|
|
1928
|
+
const isWriteOp = ["insert", "update", "delete"].includes(query.type);
|
|
1929
|
+
const needsLock = !skipLock && (isWriteOp || this.readLockEnabled);
|
|
1930
|
+
let lockAcquired = false;
|
|
1931
|
+
if (needsLock) {
|
|
1932
|
+
try {
|
|
1933
|
+
await this.lock.acquire();
|
|
1934
|
+
lockAcquired = true;
|
|
1935
|
+
} catch (err) {
|
|
1936
|
+
throwQueryError({
|
|
1937
|
+
adapter: "json",
|
|
1938
|
+
message: `Failed to acquire lock: ${err instanceof Error ? err.message : String(err)}`,
|
|
1939
|
+
query,
|
|
1940
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
try {
|
|
1945
|
+
let tableData;
|
|
1946
|
+
try {
|
|
1947
|
+
tableData = await this.readTable(query.table);
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
if (lockAcquired) await this.lock.release();
|
|
1950
|
+
throwQueryError({
|
|
1951
|
+
adapter: "json",
|
|
1952
|
+
message: `Table '${query.table}' not found`,
|
|
1953
|
+
query,
|
|
1954
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
if (!tableData.data || !Array.isArray(tableData.data)) {
|
|
1958
|
+
tableData.data = [];
|
|
1959
|
+
}
|
|
1960
|
+
let tableSchema;
|
|
1961
|
+
try {
|
|
1962
|
+
tableSchema = await this.readTableSchema(query.table);
|
|
1963
|
+
} catch {
|
|
1964
|
+
}
|
|
1965
|
+
const runner = new JsonQueryRunner(tableData, this, tableSchema);
|
|
1966
|
+
let handlerResult;
|
|
1967
|
+
switch (query.type) {
|
|
1968
|
+
case "count":
|
|
1969
|
+
handlerResult = await handleCount({ runner, query });
|
|
1970
|
+
if (handlerResult.earlyReturn) {
|
|
1971
|
+
if (lockAcquired) await this.lock.release();
|
|
1972
|
+
return {
|
|
1973
|
+
rows: [],
|
|
1974
|
+
metadata: handlerResult.metadata
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
break;
|
|
1978
|
+
case "select":
|
|
1979
|
+
handlerResult = await handleSelect({ runner, query, adapter: this });
|
|
1980
|
+
break;
|
|
1981
|
+
case "insert":
|
|
1982
|
+
handlerResult = await handleInsert({ runner, query });
|
|
1983
|
+
break;
|
|
1984
|
+
case "update":
|
|
1985
|
+
handlerResult = await handleUpdate({ runner, query });
|
|
1986
|
+
break;
|
|
1987
|
+
case "delete":
|
|
1988
|
+
handlerResult = await handleDelete({
|
|
1989
|
+
runner,
|
|
1990
|
+
query,
|
|
1991
|
+
adapter: this,
|
|
1992
|
+
queryOptions: { skipLock: true, skipWrite }
|
|
1993
|
+
});
|
|
1994
|
+
break;
|
|
1995
|
+
}
|
|
1996
|
+
const rows = handlerResult.rows;
|
|
1997
|
+
const metadata = handlerResult.metadata;
|
|
1998
|
+
const shouldWrite = handlerResult.shouldWrite;
|
|
1999
|
+
if (shouldWrite) {
|
|
2000
|
+
if (skipWrite) {
|
|
2001
|
+
if (this.activeTransactionModifiedTables) {
|
|
2002
|
+
this.activeTransactionModifiedTables.add(query.table);
|
|
2003
|
+
}
|
|
2004
|
+
} else {
|
|
2005
|
+
tableData.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2006
|
+
const filePath = this.getTablePath(query.table);
|
|
2007
|
+
await fs2.writeFile(
|
|
2008
|
+
filePath,
|
|
2009
|
+
JSON.stringify(tableData, null, 2),
|
|
2010
|
+
"utf-8"
|
|
2011
|
+
);
|
|
2012
|
+
await this.updateCache(query.table, tableData);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
metadata.rowCount = rows.length;
|
|
2016
|
+
if (lockAcquired) await this.lock.release();
|
|
2017
|
+
return {
|
|
2018
|
+
rows,
|
|
2019
|
+
metadata
|
|
2020
|
+
};
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
if (lockAcquired) await this.lock.release();
|
|
2023
|
+
throw error;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
async executeRawQuery(_sql, _params) {
|
|
2027
|
+
throwQueryError({
|
|
2028
|
+
adapter: "json",
|
|
2029
|
+
message: "executeRawQuery is not supported by JsonAdapter"
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Begin a new transaction
|
|
2034
|
+
*
|
|
2035
|
+
* Acquires lock and creates isolated transaction cache.
|
|
2036
|
+
* All reads/writes within transaction use txCache.
|
|
2037
|
+
*/
|
|
2038
|
+
async beginTransaction() {
|
|
2039
|
+
if (!this.isConnected()) {
|
|
2040
|
+
throwNotConnected({ adapter: "json" });
|
|
2041
|
+
}
|
|
2042
|
+
if (this.activeTransactionCache) {
|
|
2043
|
+
throwTransactionError({
|
|
2044
|
+
adapter: "json",
|
|
2045
|
+
message: "A transaction is already active"
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
try {
|
|
2049
|
+
await this.lock.acquire();
|
|
2050
|
+
this.activeTransactionCache = /* @__PURE__ */ new Map();
|
|
2051
|
+
this.activeTransactionModifiedTables = /* @__PURE__ */ new Set();
|
|
2052
|
+
this.activeTransactionDeletedTables = /* @__PURE__ */ new Set();
|
|
2053
|
+
const transaction = new JsonTransaction(
|
|
2054
|
+
this,
|
|
2055
|
+
// Commit callback
|
|
2056
|
+
async () => {
|
|
2057
|
+
await this.commitTransaction();
|
|
2058
|
+
},
|
|
2059
|
+
// Rollback callback
|
|
2060
|
+
async () => {
|
|
2061
|
+
await this.rollbackTransaction();
|
|
2062
|
+
}
|
|
2063
|
+
);
|
|
2064
|
+
return transaction;
|
|
2065
|
+
} catch (err) {
|
|
2066
|
+
throwTransactionError({
|
|
2067
|
+
adapter: "json",
|
|
2068
|
+
message: `Failed to begin transaction: ${err instanceof Error ? err.message : String(err)}`,
|
|
2069
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Commit transaction - write modified tables to disk
|
|
2075
|
+
* @internal Called by JsonTransaction.commit()
|
|
2076
|
+
*/
|
|
2077
|
+
async commitTransaction() {
|
|
2078
|
+
if (!this.activeTransactionCache || !this.activeTransactionModifiedTables) {
|
|
2079
|
+
throwTransactionError({
|
|
2080
|
+
adapter: "json",
|
|
2081
|
+
message: "No active transaction to commit"
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
try {
|
|
2085
|
+
if (this.activeTransactionDeletedTables) {
|
|
2086
|
+
for (const tableName of this.activeTransactionDeletedTables) {
|
|
2087
|
+
const filePath = this.getTablePath(tableName);
|
|
2088
|
+
try {
|
|
2089
|
+
await fs2.unlink(filePath);
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
this.cache.delete(tableName);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
for (const tableName of this.activeTransactionModifiedTables) {
|
|
2096
|
+
if (this.activeTransactionDeletedTables?.has(tableName)) continue;
|
|
2097
|
+
const entry = this.activeTransactionCache.get(tableName);
|
|
2098
|
+
if (entry) {
|
|
2099
|
+
entry.data.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2100
|
+
const filePath = this.getTablePath(tableName);
|
|
2101
|
+
await fs2.writeFile(
|
|
2102
|
+
filePath,
|
|
2103
|
+
JSON.stringify(entry.data, null, 2),
|
|
2104
|
+
"utf-8"
|
|
2105
|
+
);
|
|
2106
|
+
const stat = await fs2.stat(filePath);
|
|
2107
|
+
entry.mtime = stat.mtimeMs;
|
|
2108
|
+
this.cache.set(tableName, entry);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
} finally {
|
|
2112
|
+
this.activeTransactionCache = null;
|
|
2113
|
+
this.activeTransactionModifiedTables = null;
|
|
2114
|
+
this.activeTransactionDeletedTables = null;
|
|
2115
|
+
await this.lock.release();
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Rollback transaction - discard changes
|
|
2120
|
+
* @internal Called by JsonTransaction.rollback()
|
|
2121
|
+
*/
|
|
2122
|
+
async rollbackTransaction() {
|
|
2123
|
+
this.activeTransactionCache = null;
|
|
2124
|
+
this.activeTransactionModifiedTables = null;
|
|
2125
|
+
this.activeTransactionDeletedTables = null;
|
|
2126
|
+
await this.lock.release();
|
|
2127
|
+
}
|
|
2128
|
+
async alterTable(tableName, operations) {
|
|
2129
|
+
return this.alterTableWithOptions(tableName, operations);
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Alter table with options (for transaction support)
|
|
2133
|
+
*/
|
|
2134
|
+
async alterTableWithOptions(tableName, operations, options) {
|
|
2135
|
+
const skipWrite = options?.skipWrite ?? false;
|
|
2136
|
+
if (!this.isConnected()) {
|
|
2137
|
+
throwNotConnected({ adapter: "json" });
|
|
2138
|
+
}
|
|
2139
|
+
const json = await this.readTable(tableName);
|
|
2140
|
+
for (const op of operations) {
|
|
2141
|
+
switch (op.type) {
|
|
2142
|
+
case "addColumn": {
|
|
2143
|
+
const defaultValue = op.definition.default;
|
|
2144
|
+
for (const row of json.data) {
|
|
2145
|
+
if (!(op.column in row)) {
|
|
2146
|
+
row[op.column] = defaultValue ?? null;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
break;
|
|
2150
|
+
}
|
|
2151
|
+
case "dropColumn": {
|
|
2152
|
+
for (const row of json.data) {
|
|
2153
|
+
delete row[op.column];
|
|
2154
|
+
}
|
|
2155
|
+
break;
|
|
2156
|
+
}
|
|
2157
|
+
case "modifyColumn": {
|
|
2158
|
+
break;
|
|
2159
|
+
}
|
|
2160
|
+
case "renameColumn": {
|
|
2161
|
+
for (const row of json.data) {
|
|
2162
|
+
const r = row;
|
|
2163
|
+
if (op.from in r) {
|
|
2164
|
+
r[op.to] = r[op.from];
|
|
2165
|
+
delete r[op.from];
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
break;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
json.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2173
|
+
if (skipWrite) {
|
|
2174
|
+
this.activeTransactionCache.set(tableName, {
|
|
2175
|
+
data: json,
|
|
2176
|
+
mtime: Date.now()
|
|
2177
|
+
});
|
|
2178
|
+
this.activeTransactionModifiedTables.add(tableName);
|
|
2179
|
+
} else {
|
|
2180
|
+
const filePath = this.getTablePath(tableName);
|
|
2181
|
+
await fs2.writeFile(filePath, JSON.stringify(json, null, 2), "utf-8");
|
|
2182
|
+
await this.updateCache(tableName, json);
|
|
2183
|
+
}
|
|
2184
|
+
if (tableName !== FORJA_META_MODEL2) {
|
|
2185
|
+
await this.applyOperationsToMetaSchema(tableName, operations, skipWrite);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
async renameTable(from, to) {
|
|
2189
|
+
return this.renameTableWithOptions(from, to);
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Rename table with options (for transaction support)
|
|
2193
|
+
*/
|
|
2194
|
+
async renameTableWithOptions(from, to, options) {
|
|
2195
|
+
const skipWrite = options?.skipWrite ?? false;
|
|
2196
|
+
if (!this.isConnected()) {
|
|
2197
|
+
throwNotConnected({ adapter: "json" });
|
|
2198
|
+
}
|
|
2199
|
+
validateTableName(to);
|
|
2200
|
+
if (this.activeTransactionDeletedTables?.has(from)) {
|
|
2201
|
+
throwMigrationError2({
|
|
2202
|
+
adapter: "json",
|
|
2203
|
+
message: `Table '${from}' does not exist`
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
const targetExistsInTxCache = this.activeTransactionCache?.has(to);
|
|
2207
|
+
const targetExistsInMainCache = this.cache.has(to);
|
|
2208
|
+
let targetExistsOnDisk = false;
|
|
2209
|
+
if (!targetExistsInTxCache && !targetExistsInMainCache) {
|
|
2210
|
+
const toPath = this.getTablePath(to);
|
|
2211
|
+
try {
|
|
2212
|
+
await fs2.access(toPath);
|
|
2213
|
+
targetExistsOnDisk = true;
|
|
2214
|
+
} catch {
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
const targetInTombstone = this.activeTransactionDeletedTables?.has(to);
|
|
2218
|
+
if ((targetExistsInTxCache || targetExistsInMainCache || targetExistsOnDisk) && !targetInTombstone) {
|
|
2219
|
+
throwMigrationError2({
|
|
2220
|
+
adapter: "json",
|
|
2221
|
+
message: `Table '${to}' already exists`
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
const json = await this.readTable(from);
|
|
2225
|
+
json.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2226
|
+
if (skipWrite) {
|
|
2227
|
+
this.activeTransactionCache.set(to, {
|
|
2228
|
+
data: json,
|
|
2229
|
+
mtime: Date.now()
|
|
2230
|
+
});
|
|
2231
|
+
this.activeTransactionModifiedTables.add(to);
|
|
2232
|
+
this.activeTransactionDeletedTables.add(from);
|
|
2233
|
+
this.activeTransactionCache.delete(from);
|
|
2234
|
+
this.activeTransactionDeletedTables.delete(to);
|
|
2235
|
+
} else {
|
|
2236
|
+
const fromPath = this.getTablePath(from);
|
|
2237
|
+
const toPath = this.getTablePath(to);
|
|
2238
|
+
await fs2.writeFile(toPath, JSON.stringify(json, null, 2), "utf-8");
|
|
2239
|
+
await fs2.unlink(fromPath);
|
|
2240
|
+
this.invalidateCache(from);
|
|
2241
|
+
await this.updateCache(to, json);
|
|
2242
|
+
}
|
|
2243
|
+
if (from !== FORJA_META_MODEL2 && to !== FORJA_META_MODEL2) {
|
|
2244
|
+
const oldKey = `${FORJA_META_KEY_PREFIX}${from}`;
|
|
2245
|
+
const newKey = `${FORJA_META_KEY_PREFIX}${to}`;
|
|
2246
|
+
const metaFile = await this.readTable(FORJA_META_MODEL2);
|
|
2247
|
+
const row = metaFile.data.find(
|
|
2248
|
+
(r) => r["key"] === oldKey
|
|
2249
|
+
);
|
|
2250
|
+
if (row) {
|
|
2251
|
+
row["key"] = newKey;
|
|
2252
|
+
metaFile.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2253
|
+
if (skipWrite) {
|
|
2254
|
+
this.activeTransactionCache.set(FORJA_META_MODEL2, {
|
|
2255
|
+
data: metaFile,
|
|
2256
|
+
mtime: Date.now()
|
|
2257
|
+
});
|
|
2258
|
+
this.activeTransactionModifiedTables.add(FORJA_META_MODEL2);
|
|
2259
|
+
} else {
|
|
2260
|
+
const metaPath = this.getTablePath(FORJA_META_MODEL2);
|
|
2261
|
+
await fs2.writeFile(
|
|
2262
|
+
metaPath,
|
|
2263
|
+
JSON.stringify(metaFile, null, 2),
|
|
2264
|
+
"utf-8"
|
|
2265
|
+
);
|
|
2266
|
+
await this.updateCache(FORJA_META_MODEL2, metaFile);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
async addIndex(tableName, index) {
|
|
2272
|
+
return this.addIndexWithOptions(tableName, index);
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Add index with options (for transaction support)
|
|
2276
|
+
* Note: JSON adapter doesn't actually create indexes, but we track the operation
|
|
2277
|
+
*/
|
|
2278
|
+
async addIndexWithOptions(_tableName, _index, _options) {
|
|
2279
|
+
}
|
|
2280
|
+
async dropIndex(tableName, indexName) {
|
|
2281
|
+
return this.dropIndexWithOptions(tableName, indexName);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Drop index with options (for transaction support)
|
|
2285
|
+
* Note: JSON adapter doesn't actually manage indexes, but we track the operation
|
|
2286
|
+
*/
|
|
2287
|
+
async dropIndexWithOptions(_tableName, _indexName, _options) {
|
|
2288
|
+
}
|
|
2289
|
+
async getTables() {
|
|
2290
|
+
if (!this.isConnected()) {
|
|
2291
|
+
throwNotConnected({ adapter: "json" });
|
|
2292
|
+
}
|
|
2293
|
+
const files = await fs2.readdir(this.config.root);
|
|
2294
|
+
const tables = files.filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
|
|
2295
|
+
return tables;
|
|
2296
|
+
}
|
|
2297
|
+
async getTableSchema(tableName) {
|
|
2298
|
+
if (!this.isConnected()) {
|
|
2299
|
+
throwNotConnected({ adapter: "json" });
|
|
2300
|
+
}
|
|
2301
|
+
try {
|
|
2302
|
+
const schema = await this.readTableSchema(tableName);
|
|
2303
|
+
return schema;
|
|
2304
|
+
} catch {
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
async tableExists(tableName) {
|
|
2309
|
+
if (!this.isConnected()) return false;
|
|
2310
|
+
try {
|
|
2311
|
+
await fs2.access(this.getTablePath(tableName));
|
|
2312
|
+
return true;
|
|
2313
|
+
} catch {
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
export {
|
|
2319
|
+
JsonAdapter
|
|
2320
|
+
};
|
|
2321
|
+
//# sourceMappingURL=index.mjs.map
|