js-bao 0.2.10 → 0.2.12
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/README.md +174 -0
- package/dist/BaseModel-5YQCROYE.js +17 -0
- package/dist/BaseModel-5YQCROYE.js.map +1 -0
- package/dist/BaseModel-FCNWDJBH.js +17 -0
- package/dist/BaseModel-FCNWDJBH.js.map +1 -0
- package/dist/BrowserDatabaseFactory-PXOTK2DQ.js +119 -0
- package/dist/BrowserDatabaseFactory-PXOTK2DQ.js.map +1 -0
- package/dist/BrowserDatabaseFactory-WD4VX2VZ.js +119 -0
- package/dist/BrowserDatabaseFactory-WD4VX2VZ.js.map +1 -0
- package/dist/IncludeResolver-RCKQGNPZ.js +385 -0
- package/dist/IncludeResolver-RCKQGNPZ.js.map +1 -0
- package/dist/IncludeResolver-WGSQDMS7.js +385 -0
- package/dist/IncludeResolver-WGSQDMS7.js.map +1 -0
- package/dist/NodeDatabaseFactory-J4Z36UF3.js +165 -0
- package/dist/NodeDatabaseFactory-J4Z36UF3.js.map +1 -0
- package/dist/NodeDatabaseFactory-QIEKAXBM.js +10 -0
- package/dist/NodeDatabaseFactory-QIEKAXBM.js.map +1 -0
- package/dist/NodeSqliteEngine-HJSAYE4E.js +383 -0
- package/dist/NodeSqliteEngine-HJSAYE4E.js.map +1 -0
- package/dist/NodeSqliteEngine-I5SLWLME.js +383 -0
- package/dist/NodeSqliteEngine-I5SLWLME.js.map +1 -0
- package/dist/browser.cjs +3779 -3370
- package/dist/browser.d.cts +18 -1
- package/dist/browser.d.ts +18 -1
- package/dist/browser.js +3750 -3341
- package/dist/chunk-3PZWHUZO.js +4153 -0
- package/dist/chunk-3PZWHUZO.js.map +1 -0
- package/dist/chunk-53MS4MN7.js +373 -0
- package/dist/chunk-53MS4MN7.js.map +1 -0
- package/dist/chunk-65G2P4GL.js +709 -0
- package/dist/chunk-65G2P4GL.js.map +1 -0
- package/dist/chunk-6UX3YSCW.js +4151 -0
- package/dist/chunk-6UX3YSCW.js.map +1 -0
- package/dist/chunk-DANSD6BE.js +709 -0
- package/dist/chunk-DANSD6BE.js.map +1 -0
- package/dist/chunk-DF3JEQXA.js +373 -0
- package/dist/chunk-DF3JEQXA.js.map +1 -0
- package/dist/chunk-GO3APTPX.js +61 -0
- package/dist/chunk-GO3APTPX.js.map +1 -0
- package/dist/chunk-ID4U6IQC.js +53 -0
- package/dist/chunk-ID4U6IQC.js.map +1 -0
- package/dist/chunk-RQVS3LVL.js +165 -0
- package/dist/chunk-RQVS3LVL.js.map +1 -0
- package/dist/client.cjs +837 -0
- package/dist/client.d.cts +1101 -0
- package/dist/client.d.ts +1101 -0
- package/dist/client.js +806 -0
- package/dist/cloudflare-do.cjs +3637 -0
- package/dist/cloudflare-do.d.cts +1366 -0
- package/dist/cloudflare-do.d.ts +1366 -0
- package/dist/cloudflare-do.js +3614 -0
- package/dist/cloudflare.cjs +1048 -0
- package/dist/cloudflare.d.cts +1381 -0
- package/dist/cloudflare.d.ts +1381 -0
- package/dist/cloudflare.js +1017 -0
- package/dist/codegen.cjs +259 -18
- package/dist/environment-TOTQICSE.js +17 -0
- package/dist/environment-TOTQICSE.js.map +1 -0
- package/dist/index.cjs +1906 -1493
- package/dist/index.d.cts +19 -2
- package/dist/index.d.ts +19 -2
- package/dist/index.js +1871 -1458
- package/dist/node.cjs +4779 -4366
- package/dist/node.d.cts +18 -1
- package/dist/node.d.ts +18 -1
- package/dist/node.js +4602 -4189
- package/package.json +41 -12
|
@@ -0,0 +1,3637 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/cloudflare-do.ts
|
|
21
|
+
var cloudflare_do_exports = {};
|
|
22
|
+
__export(cloudflare_do_exports, {
|
|
23
|
+
DurableObjectEngine: () => DurableObjectEngine,
|
|
24
|
+
JsonQueryTranslator: () => JsonQueryTranslator,
|
|
25
|
+
JsonSchemaDDL: () => JsonSchemaDDL,
|
|
26
|
+
createDatabaseDO: () => createDatabaseDO,
|
|
27
|
+
createDocumentDO: () => createDocumentDO,
|
|
28
|
+
handleRequest: () => handleRequest
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(cloudflare_do_exports);
|
|
31
|
+
|
|
32
|
+
// src/engines/DatabaseEngine.ts
|
|
33
|
+
var DatabaseEngine = class {
|
|
34
|
+
createTable(_modelName, _schema, _options) {
|
|
35
|
+
throw new Error("Method not implemented.");
|
|
36
|
+
}
|
|
37
|
+
createStringSetJunctionTable(_modelName, _fieldName) {
|
|
38
|
+
throw new Error("Method not implemented.");
|
|
39
|
+
}
|
|
40
|
+
insertStringSetValues(_modelName, _fieldName, _recordId, _values) {
|
|
41
|
+
throw new Error("Method not implemented.");
|
|
42
|
+
}
|
|
43
|
+
removeStringSetValues(_modelName, _fieldName, _recordId, _values) {
|
|
44
|
+
throw new Error("Method not implemented.");
|
|
45
|
+
}
|
|
46
|
+
insert(_modelName, _data) {
|
|
47
|
+
throw new Error("Method not implemented.");
|
|
48
|
+
}
|
|
49
|
+
delete(_modelName, _id) {
|
|
50
|
+
throw new Error("Method not implemented.");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Deletes all records for a specific document from the given model table.
|
|
54
|
+
* This is used when disconnecting a document to remove all its data.
|
|
55
|
+
* @param modelName The name of the model/table
|
|
56
|
+
* @param docId The document ID to filter by
|
|
57
|
+
*/
|
|
58
|
+
deleteByDocumentId(_modelName, _docId) {
|
|
59
|
+
throw new Error("Method not implemented.");
|
|
60
|
+
}
|
|
61
|
+
getTableName(_modelName) {
|
|
62
|
+
throw new Error("Method not implemented.");
|
|
63
|
+
}
|
|
64
|
+
// Transaction support
|
|
65
|
+
async withTransaction(_callback) {
|
|
66
|
+
throw new Error("Method not implemented.");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// src/utils/sql.ts
|
|
71
|
+
var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
72
|
+
function isValidIdentifier(name) {
|
|
73
|
+
return IDENTIFIER_PATTERN.test(name);
|
|
74
|
+
}
|
|
75
|
+
function assertValidIdentifier(name, context) {
|
|
76
|
+
if (!isValidIdentifier(name)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${context}: Identifier "${name}" must match ${IDENTIFIER_PATTERN.source}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function quoteIdentifier(name) {
|
|
83
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/schema/JsonSchemaDDL.ts
|
|
87
|
+
var JsonSchemaDDL = class {
|
|
88
|
+
/**
|
|
89
|
+
* Generate CREATE TABLE statement for the records table
|
|
90
|
+
*/
|
|
91
|
+
static createRecordsTable(options) {
|
|
92
|
+
if (options.includeDocIdColumn) {
|
|
93
|
+
return `
|
|
94
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
95
|
+
_id TEXT NOT NULL,
|
|
96
|
+
_type TEXT NOT NULL,
|
|
97
|
+
_data TEXT NOT NULL,
|
|
98
|
+
_meta_doc_id TEXT,
|
|
99
|
+
_meta_permission_hint TEXT,
|
|
100
|
+
PRIMARY KEY (_type, _id)
|
|
101
|
+
)`.trim();
|
|
102
|
+
} else {
|
|
103
|
+
return `
|
|
104
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
105
|
+
_id TEXT NOT NULL,
|
|
106
|
+
_type TEXT NOT NULL,
|
|
107
|
+
_data TEXT NOT NULL,
|
|
108
|
+
PRIMARY KEY (_type, _id)
|
|
109
|
+
)`.trim();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Generate CREATE TABLE statement for the stringset_index table
|
|
114
|
+
*/
|
|
115
|
+
static createStringSetIndexTable(options) {
|
|
116
|
+
if (options.includeDocIdColumn) {
|
|
117
|
+
return `
|
|
118
|
+
CREATE TABLE IF NOT EXISTS stringset_index (
|
|
119
|
+
_record_id TEXT NOT NULL,
|
|
120
|
+
_type TEXT NOT NULL,
|
|
121
|
+
field TEXT NOT NULL,
|
|
122
|
+
value TEXT NOT NULL,
|
|
123
|
+
_meta_doc_id TEXT,
|
|
124
|
+
UNIQUE(_type, field, _record_id, value)
|
|
125
|
+
)`.trim();
|
|
126
|
+
} else {
|
|
127
|
+
return `
|
|
128
|
+
CREATE TABLE IF NOT EXISTS stringset_index (
|
|
129
|
+
_record_id TEXT NOT NULL,
|
|
130
|
+
_type TEXT NOT NULL,
|
|
131
|
+
field TEXT NOT NULL,
|
|
132
|
+
value TEXT NOT NULL,
|
|
133
|
+
UNIQUE(_type, field, _record_id, value)
|
|
134
|
+
)`.trim();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Generate base indexes for the schema
|
|
139
|
+
*/
|
|
140
|
+
static createBaseIndexes(options) {
|
|
141
|
+
const indexes = [
|
|
142
|
+
// Index on _type for filtering by model
|
|
143
|
+
"CREATE INDEX IF NOT EXISTS idx_records_type ON records(_type)",
|
|
144
|
+
// StringSet indexes for efficient lookups
|
|
145
|
+
"CREATE INDEX IF NOT EXISTS idx_ss_type_field_value ON stringset_index(_type, field, value)",
|
|
146
|
+
"CREATE INDEX IF NOT EXISTS idx_ss_type_field_record ON stringset_index(_type, field, _record_id)"
|
|
147
|
+
];
|
|
148
|
+
if (options.includeDocIdColumn) {
|
|
149
|
+
indexes.push(
|
|
150
|
+
"CREATE INDEX IF NOT EXISTS idx_records_doc ON records(_meta_doc_id)",
|
|
151
|
+
"CREATE INDEX IF NOT EXISTS idx_ss_doc ON stringset_index(_meta_doc_id)"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return indexes;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Generate CREATE INDEX statement for a model field
|
|
158
|
+
* Uses json_extract for JSON schema
|
|
159
|
+
*/
|
|
160
|
+
static createFieldIndex(modelName, fieldName) {
|
|
161
|
+
assertValidIdentifier(modelName, "createFieldIndex modelName");
|
|
162
|
+
assertValidIdentifier(fieldName, "createFieldIndex fieldName");
|
|
163
|
+
const indexName = `idx_records_${modelName.toLowerCase()}_${fieldName.toLowerCase()}`;
|
|
164
|
+
return `CREATE INDEX IF NOT EXISTS ${indexName} ON records(json_extract(_data, '$.${fieldName}')) WHERE _type = '${modelName}'`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Generate DROP INDEX statement for a model field
|
|
168
|
+
*/
|
|
169
|
+
static dropFieldIndex(modelName, fieldName) {
|
|
170
|
+
assertValidIdentifier(modelName, "dropFieldIndex modelName");
|
|
171
|
+
assertValidIdentifier(fieldName, "dropFieldIndex fieldName");
|
|
172
|
+
const indexName = `idx_records_${modelName.toLowerCase()}_${fieldName.toLowerCase()}`;
|
|
173
|
+
return `DROP INDEX IF EXISTS ${indexName}`;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Generate CREATE TABLE statement for the _indexes table.
|
|
177
|
+
* Stores per-field index registrations (one row per index).
|
|
178
|
+
*/
|
|
179
|
+
static createIndexesTable() {
|
|
180
|
+
return `
|
|
181
|
+
CREATE TABLE IF NOT EXISTS _indexes (
|
|
182
|
+
model_name TEXT NOT NULL,
|
|
183
|
+
field_name TEXT NOT NULL,
|
|
184
|
+
field_type TEXT NOT NULL,
|
|
185
|
+
is_unique INTEGER DEFAULT 0,
|
|
186
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
187
|
+
PRIMARY KEY (model_name, field_name)
|
|
188
|
+
)`.trim();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Generate CREATE TABLE statement for the _unique_constraints table.
|
|
192
|
+
* Stores composite (multi-field) unique constraints.
|
|
193
|
+
*/
|
|
194
|
+
static createUniqueConstraintsTable() {
|
|
195
|
+
return `
|
|
196
|
+
CREATE TABLE IF NOT EXISTS _unique_constraints (
|
|
197
|
+
model_name TEXT NOT NULL,
|
|
198
|
+
constraint_name TEXT NOT NULL,
|
|
199
|
+
fields_json TEXT NOT NULL,
|
|
200
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
201
|
+
PRIMARY KEY (model_name, constraint_name)
|
|
202
|
+
)`.trim();
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Generate CREATE TABLE statement for the _model_fields table.
|
|
206
|
+
* Tracks field names and inferred types as records are written.
|
|
207
|
+
*/
|
|
208
|
+
static createModelFieldsTable() {
|
|
209
|
+
return `
|
|
210
|
+
CREATE TABLE IF NOT EXISTS _model_fields (
|
|
211
|
+
model_name TEXT NOT NULL,
|
|
212
|
+
field_name TEXT NOT NULL,
|
|
213
|
+
inferred_type TEXT NOT NULL,
|
|
214
|
+
first_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
215
|
+
PRIMARY KEY (model_name, field_name)
|
|
216
|
+
)`.trim();
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Generate all DDL statements needed to initialize the schema
|
|
220
|
+
*/
|
|
221
|
+
static getAllDDL(options) {
|
|
222
|
+
return [
|
|
223
|
+
this.createRecordsTable(options),
|
|
224
|
+
this.createStringSetIndexTable(options),
|
|
225
|
+
this.createIndexesTable(),
|
|
226
|
+
this.createUniqueConstraintsTable(),
|
|
227
|
+
this.createModelFieldsTable(),
|
|
228
|
+
...this.createBaseIndexes(options)
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Generate INSERT statement for a record
|
|
233
|
+
*/
|
|
234
|
+
static buildInsertSQL(options) {
|
|
235
|
+
if (options.includeDocIdColumn) {
|
|
236
|
+
return `
|
|
237
|
+
INSERT OR REPLACE INTO records (_id, _type, _data, _meta_doc_id, _meta_permission_hint)
|
|
238
|
+
VALUES (?, ?, ?, ?, ?)`.trim();
|
|
239
|
+
} else {
|
|
240
|
+
return `
|
|
241
|
+
INSERT OR REPLACE INTO records (_id, _type, _data)
|
|
242
|
+
VALUES (?, ?, ?)`.trim();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Generate DELETE statement for a record by id and type
|
|
247
|
+
*/
|
|
248
|
+
static buildDeleteSQL(options) {
|
|
249
|
+
if (options.includeDocIdColumn) {
|
|
250
|
+
return "DELETE FROM records WHERE _type = ? AND _id = ?";
|
|
251
|
+
} else {
|
|
252
|
+
return "DELETE FROM records WHERE _type = ? AND _id = ?";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Generate DELETE statement for stringset values
|
|
257
|
+
*/
|
|
258
|
+
static buildDeleteStringSetSQL() {
|
|
259
|
+
return "DELETE FROM stringset_index WHERE _type = ? AND _record_id = ?";
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Generate INSERT statement for stringset values
|
|
263
|
+
*/
|
|
264
|
+
static buildInsertStringSetSQL(options) {
|
|
265
|
+
if (options.includeDocIdColumn) {
|
|
266
|
+
return `
|
|
267
|
+
INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value, _meta_doc_id)
|
|
268
|
+
VALUES (?, ?, ?, ?, ?)`.trim();
|
|
269
|
+
} else {
|
|
270
|
+
return `
|
|
271
|
+
INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value)
|
|
272
|
+
VALUES (?, ?, ?, ?)`.trim();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/engines/JsonSchemaEngine.ts
|
|
278
|
+
var JsonSchemaEngine = class extends DatabaseEngine {
|
|
279
|
+
schemaOptions;
|
|
280
|
+
initialized = false;
|
|
281
|
+
constructor(options) {
|
|
282
|
+
super();
|
|
283
|
+
this.schemaOptions = options;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Initialize the JSON schema tables.
|
|
287
|
+
* Should be called once during engine setup.
|
|
288
|
+
*/
|
|
289
|
+
async initializeSchema() {
|
|
290
|
+
if (this.initialized) return;
|
|
291
|
+
const ddlStatements = JsonSchemaDDL.getAllDDL(this.schemaOptions);
|
|
292
|
+
for (const ddl of ddlStatements) {
|
|
293
|
+
await this.execSql(ddl);
|
|
294
|
+
}
|
|
295
|
+
this.initialized = true;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Create table for a model.
|
|
299
|
+
* In JSON schema, this is a no-op since all models share the `records` table.
|
|
300
|
+
* However, we can create indexes for indexed fields.
|
|
301
|
+
*/
|
|
302
|
+
async createTable(modelName, schema, _options) {
|
|
303
|
+
await this.initializeSchema();
|
|
304
|
+
for (const [fieldName, fieldOptions] of schema) {
|
|
305
|
+
if (fieldOptions.indexed) {
|
|
306
|
+
const indexDdl = JsonSchemaDDL.createFieldIndex(modelName, fieldName);
|
|
307
|
+
await this.execSql(indexDdl);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Create StringSet junction table.
|
|
313
|
+
* In JSON schema, this is a no-op since we use the shared stringset_index table.
|
|
314
|
+
*/
|
|
315
|
+
async createStringSetJunctionTable(_modelName, _fieldName) {
|
|
316
|
+
await this.initializeSchema();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Insert or update a record.
|
|
320
|
+
*/
|
|
321
|
+
async insert(modelName, data) {
|
|
322
|
+
await this.initializeSchema();
|
|
323
|
+
const { id, _meta_doc_id, _meta_permission_hint, ...fieldsForJson } = data;
|
|
324
|
+
const dataJson = JSON.stringify(fieldsForJson);
|
|
325
|
+
const sql = JsonSchemaDDL.buildInsertSQL(this.schemaOptions);
|
|
326
|
+
if (this.schemaOptions.includeDocIdColumn) {
|
|
327
|
+
await this.execSql(sql, [
|
|
328
|
+
id,
|
|
329
|
+
modelName,
|
|
330
|
+
dataJson,
|
|
331
|
+
_meta_doc_id || null,
|
|
332
|
+
_meta_permission_hint || null
|
|
333
|
+
]);
|
|
334
|
+
} else {
|
|
335
|
+
await this.execSql(sql, [id, modelName, dataJson]);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Delete a record by ID.
|
|
340
|
+
*/
|
|
341
|
+
async delete(modelName, id) {
|
|
342
|
+
const sql = JsonSchemaDDL.buildDeleteSQL(this.schemaOptions);
|
|
343
|
+
await this.execSql(sql, [modelName, id]);
|
|
344
|
+
const ssDeleteSql = JsonSchemaDDL.buildDeleteStringSetSQL();
|
|
345
|
+
await this.execSql(ssDeleteSql, [modelName, id]);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Delete all records for a specific document.
|
|
349
|
+
* Only applicable in yjs mode with doc ID column.
|
|
350
|
+
*/
|
|
351
|
+
async deleteByDocumentId(modelName, docId) {
|
|
352
|
+
if (!this.schemaOptions.includeDocIdColumn) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
"deleteByDocumentId is only supported in yjs mode with doc ID column"
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
await this.execSql(
|
|
358
|
+
"DELETE FROM records WHERE _type = ? AND _meta_doc_id = ?",
|
|
359
|
+
[modelName, docId]
|
|
360
|
+
);
|
|
361
|
+
await this.execSql(
|
|
362
|
+
"DELETE FROM stringset_index WHERE _type = ? AND _meta_doc_id = ?",
|
|
363
|
+
[modelName, docId]
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Insert StringSet values for a record.
|
|
368
|
+
*/
|
|
369
|
+
async insertStringSetValues(modelName, fieldName, recordId, values, docId) {
|
|
370
|
+
if (values.length === 0) return;
|
|
371
|
+
const sql = JsonSchemaDDL.buildInsertStringSetSQL(this.schemaOptions);
|
|
372
|
+
for (const value of values) {
|
|
373
|
+
if (this.schemaOptions.includeDocIdColumn) {
|
|
374
|
+
await this.execSql(sql, [
|
|
375
|
+
recordId,
|
|
376
|
+
modelName,
|
|
377
|
+
fieldName,
|
|
378
|
+
value,
|
|
379
|
+
docId || null
|
|
380
|
+
]);
|
|
381
|
+
} else {
|
|
382
|
+
await this.execSql(sql, [recordId, modelName, fieldName, value]);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Remove StringSet values for a record.
|
|
388
|
+
*/
|
|
389
|
+
async removeStringSetValues(modelName, fieldName, recordId, values) {
|
|
390
|
+
if (values.length === 0) return;
|
|
391
|
+
const placeholders = values.map(() => "?").join(", ");
|
|
392
|
+
const sql = `DELETE FROM stringset_index WHERE _type = ? AND field = ? AND _record_id = ? AND value IN (${placeholders})`;
|
|
393
|
+
await this.execSql(sql, [modelName, fieldName, recordId, ...values]);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Get table name for a model.
|
|
397
|
+
* In JSON schema, all models use the 'records' table.
|
|
398
|
+
*/
|
|
399
|
+
getTableName(_modelName) {
|
|
400
|
+
return "records";
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Execute a query and return results.
|
|
404
|
+
*/
|
|
405
|
+
async query(sql, params) {
|
|
406
|
+
return this.execSql(sql, params);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Get the table schema.
|
|
410
|
+
* Returns information about the records table structure.
|
|
411
|
+
*/
|
|
412
|
+
async getTableSchema(_tableName) {
|
|
413
|
+
return this.execSql("PRAGMA table_info(records)");
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get the last error message.
|
|
417
|
+
* Subclasses can override this if needed.
|
|
418
|
+
*/
|
|
419
|
+
getLastErrorMessage() {
|
|
420
|
+
return void 0;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Transaction support.
|
|
424
|
+
* Default implementation runs callback without actual transaction.
|
|
425
|
+
* Subclasses should override for proper transaction support.
|
|
426
|
+
*/
|
|
427
|
+
async withTransaction(callback) {
|
|
428
|
+
const ops = {
|
|
429
|
+
insert: async (modelName, data) => {
|
|
430
|
+
await this.insert(modelName, data);
|
|
431
|
+
},
|
|
432
|
+
delete: async (modelName, id) => {
|
|
433
|
+
await this.delete(modelName, id);
|
|
434
|
+
},
|
|
435
|
+
query: async (sql, params) => {
|
|
436
|
+
return await this.execSql(sql, params);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
return callback(ops);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Parse a record row from the database.
|
|
443
|
+
* Extracts fields from _data and merges with direct columns.
|
|
444
|
+
*/
|
|
445
|
+
parseRecordRow(row) {
|
|
446
|
+
if (!row) return row;
|
|
447
|
+
const { _id, _type, _data, _meta_doc_id, _meta_permission_hint, ...rest } = row;
|
|
448
|
+
let parsedData = {};
|
|
449
|
+
if (_data) {
|
|
450
|
+
try {
|
|
451
|
+
parsedData = JSON.parse(_data);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
console.warn("Failed to parse _data:", e);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const result = {
|
|
457
|
+
id: _id,
|
|
458
|
+
type: _type,
|
|
459
|
+
...parsedData,
|
|
460
|
+
...rest
|
|
461
|
+
// Include any projected fields
|
|
462
|
+
};
|
|
463
|
+
if (_meta_doc_id !== void 0) {
|
|
464
|
+
result._meta_doc_id = _meta_doc_id;
|
|
465
|
+
}
|
|
466
|
+
if (_meta_permission_hint !== void 0) {
|
|
467
|
+
result._meta_permission_hint = _meta_permission_hint;
|
|
468
|
+
}
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// src/engines/cloudflare/DurableObjectEngine.ts
|
|
474
|
+
var DurableObjectEngine = class extends JsonSchemaEngine {
|
|
475
|
+
sql;
|
|
476
|
+
storage;
|
|
477
|
+
constructor(options) {
|
|
478
|
+
super({ includeDocIdColumn: false });
|
|
479
|
+
this.sql = options.sql;
|
|
480
|
+
this.storage = options.storage;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Initialize the engine — creates tables and restores persisted indexes.
|
|
484
|
+
*/
|
|
485
|
+
async ensureReady() {
|
|
486
|
+
await this.initializeSchema();
|
|
487
|
+
this._migrateSchema();
|
|
488
|
+
this.loadPersistedIndexes();
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Handle schema migrations for existing databases.
|
|
492
|
+
* Adds columns/tables that were added in later versions.
|
|
493
|
+
*/
|
|
494
|
+
_migrateSchema() {
|
|
495
|
+
const cols = this.execSqlSync("PRAGMA table_info(_indexes)");
|
|
496
|
+
if (!cols.some((c) => c.name === "is_unique")) {
|
|
497
|
+
this.execSqlSync(
|
|
498
|
+
"ALTER TABLE _indexes ADD COLUMN is_unique INTEGER DEFAULT 0"
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
this.execSqlSync(`
|
|
502
|
+
CREATE TABLE IF NOT EXISTS _model_fields (
|
|
503
|
+
model_name TEXT NOT NULL,
|
|
504
|
+
field_name TEXT NOT NULL,
|
|
505
|
+
inferred_type TEXT NOT NULL,
|
|
506
|
+
first_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
507
|
+
PRIMARY KEY (model_name, field_name)
|
|
508
|
+
)
|
|
509
|
+
`);
|
|
510
|
+
const recordsCols = this.execSqlSync("PRAGMA table_info(records)");
|
|
511
|
+
const hasOldSchema = recordsCols.some((c) => c.name === "id");
|
|
512
|
+
if (hasOldSchema) {
|
|
513
|
+
this.execSqlSync("ALTER TABLE records RENAME COLUMN id TO _id");
|
|
514
|
+
this.execSqlSync("ALTER TABLE records RENAME COLUMN type TO _type");
|
|
515
|
+
this.execSqlSync("ALTER TABLE records RENAME COLUMN data_json TO _data");
|
|
516
|
+
this.execSqlSync("ALTER TABLE stringset_index RENAME COLUMN record_id TO _record_id");
|
|
517
|
+
this.execSqlSync("ALTER TABLE stringset_index RENAME COLUMN type TO _type");
|
|
518
|
+
this.execSqlSync("DROP INDEX IF EXISTS idx_records_type");
|
|
519
|
+
this.execSqlSync("CREATE INDEX IF NOT EXISTS idx_records_type ON records(_type)");
|
|
520
|
+
this.execSqlSync("DROP INDEX IF EXISTS idx_ss_type_field_value");
|
|
521
|
+
this.execSqlSync("CREATE INDEX IF NOT EXISTS idx_ss_type_field_value ON stringset_index(_type, field, value)");
|
|
522
|
+
this.execSqlSync("DROP INDEX IF EXISTS idx_ss_type_field_record");
|
|
523
|
+
this.execSqlSync("CREATE INDEX IF NOT EXISTS idx_ss_type_field_record ON stringset_index(_type, field, _record_id)");
|
|
524
|
+
try {
|
|
525
|
+
const indexes = this.execSqlSync(
|
|
526
|
+
"SELECT model_name, field_name FROM _indexes"
|
|
527
|
+
);
|
|
528
|
+
for (const row of indexes) {
|
|
529
|
+
const mn = row.model_name;
|
|
530
|
+
const fn = row.field_name;
|
|
531
|
+
const idxName = `idx_records_${mn.toLowerCase()}_${fn.toLowerCase()}`;
|
|
532
|
+
this.execSqlSync(`DROP INDEX IF EXISTS ${idxName}`);
|
|
533
|
+
this.execSqlSync(
|
|
534
|
+
`CREATE INDEX IF NOT EXISTS ${idxName} ON records(json_extract(_data, '$.${fn}')) WHERE _type = '${mn}'`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
} catch (e) {
|
|
538
|
+
console.debug("Could not recreate field indexes during migration:", e);
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const constraints = this.execSqlSync(
|
|
542
|
+
"SELECT model_name, constraint_name, fields_json FROM _unique_constraints"
|
|
543
|
+
);
|
|
544
|
+
for (const row of constraints) {
|
|
545
|
+
const mn = row.model_name;
|
|
546
|
+
const cn = row.constraint_name;
|
|
547
|
+
const fields = JSON.parse(row.fields_json);
|
|
548
|
+
const idxName = `idx_uc_${mn.toLowerCase()}_${cn.toLowerCase()}`;
|
|
549
|
+
this.execSqlSync(`DROP INDEX IF EXISTS ${idxName}`);
|
|
550
|
+
const fieldExprs = fields.map((f) => `json_extract(_data, '$.${f}')`).join(", ");
|
|
551
|
+
this.execSqlSync(
|
|
552
|
+
`CREATE INDEX IF NOT EXISTS ${idxName} ON records(${fieldExprs}) WHERE _type = '${mn}'`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
} catch (e) {
|
|
556
|
+
console.debug("Could not recreate unique constraint indexes during migration:", e);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Load indexes from the _indexes table and re-ensure they exist.
|
|
562
|
+
*/
|
|
563
|
+
loadPersistedIndexes() {
|
|
564
|
+
try {
|
|
565
|
+
const rows = this.execSqlSync(
|
|
566
|
+
"SELECT model_name, field_name FROM _indexes"
|
|
567
|
+
);
|
|
568
|
+
for (const row of rows) {
|
|
569
|
+
const createSql = JsonSchemaDDL.createFieldIndex(
|
|
570
|
+
row.model_name,
|
|
571
|
+
row.field_name
|
|
572
|
+
);
|
|
573
|
+
this.execSqlSync(createSql);
|
|
574
|
+
}
|
|
575
|
+
} catch (e) {
|
|
576
|
+
console.debug("Could not load persisted indexes:", e);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Register an index on a model field.
|
|
581
|
+
* Idempotent — re-registering the same index is a no-op.
|
|
582
|
+
*/
|
|
583
|
+
registerIndex(modelName, fieldName, fieldType, unique = false) {
|
|
584
|
+
assertValidIdentifier(modelName, "registerIndex modelName");
|
|
585
|
+
assertValidIdentifier(fieldName, "registerIndex fieldName");
|
|
586
|
+
this.execSqlSync(
|
|
587
|
+
`INSERT OR REPLACE INTO _indexes (model_name, field_name, field_type, is_unique)
|
|
588
|
+
VALUES (?, ?, ?, ?)`,
|
|
589
|
+
[modelName, fieldName, fieldType, unique ? 1 : 0]
|
|
590
|
+
);
|
|
591
|
+
const createSql = JsonSchemaDDL.createFieldIndex(modelName, fieldName);
|
|
592
|
+
this.execSqlSync(createSql);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Drop an index on a model field.
|
|
596
|
+
* Idempotent — dropping a non-existent index is a no-op.
|
|
597
|
+
*/
|
|
598
|
+
dropIndex(modelName, fieldName) {
|
|
599
|
+
assertValidIdentifier(modelName, "dropIndex modelName");
|
|
600
|
+
assertValidIdentifier(fieldName, "dropIndex fieldName");
|
|
601
|
+
this.execSqlSync(
|
|
602
|
+
"DELETE FROM _indexes WHERE model_name = ? AND field_name = ?",
|
|
603
|
+
[modelName, fieldName]
|
|
604
|
+
);
|
|
605
|
+
const dropSql = JsonSchemaDDL.dropFieldIndex(modelName, fieldName);
|
|
606
|
+
this.execSqlSync(dropSql);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* List all registered indexes, optionally filtered by model.
|
|
610
|
+
*/
|
|
611
|
+
listIndexes(modelName) {
|
|
612
|
+
if (modelName) {
|
|
613
|
+
return this.execSqlSync(
|
|
614
|
+
"SELECT model_name, field_name, field_type, is_unique, created_at FROM _indexes WHERE model_name = ?",
|
|
615
|
+
[modelName]
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
return this.execSqlSync(
|
|
619
|
+
"SELECT model_name, field_name, field_type, is_unique, created_at FROM _indexes"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Get unique single-field indexes for a model.
|
|
624
|
+
*/
|
|
625
|
+
getUniqueIndexes(modelName) {
|
|
626
|
+
return this.execSqlSync(
|
|
627
|
+
"SELECT model_name, field_name, field_type, is_unique, created_at FROM _indexes WHERE model_name = ? AND is_unique = 1",
|
|
628
|
+
[modelName]
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Register a composite unique constraint.
|
|
633
|
+
* Idempotent — re-registering the same constraint is a no-op.
|
|
634
|
+
*/
|
|
635
|
+
registerUniqueConstraint(modelName, constraintName, fields) {
|
|
636
|
+
assertValidIdentifier(modelName, "registerUniqueConstraint modelName");
|
|
637
|
+
assertValidIdentifier(constraintName, "registerUniqueConstraint constraintName");
|
|
638
|
+
for (const f of fields) {
|
|
639
|
+
assertValidIdentifier(f, "registerUniqueConstraint field");
|
|
640
|
+
}
|
|
641
|
+
this.execSqlSync(
|
|
642
|
+
`INSERT OR REPLACE INTO _unique_constraints (model_name, constraint_name, fields_json)
|
|
643
|
+
VALUES (?, ?, ?)`,
|
|
644
|
+
[modelName, constraintName, JSON.stringify(fields)]
|
|
645
|
+
);
|
|
646
|
+
const fieldExprs = fields.map((f) => `json_extract(_data, '$.${f}')`).join(", ");
|
|
647
|
+
const indexName = `idx_uc_${modelName.toLowerCase()}_${constraintName.toLowerCase()}`;
|
|
648
|
+
this.execSqlSync(
|
|
649
|
+
`CREATE INDEX IF NOT EXISTS ${indexName} ON records(${fieldExprs}) WHERE _type = '${modelName}'`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Drop a composite unique constraint.
|
|
654
|
+
* Idempotent — dropping a non-existent constraint is a no-op.
|
|
655
|
+
*/
|
|
656
|
+
dropUniqueConstraint(modelName, constraintName) {
|
|
657
|
+
this.execSqlSync(
|
|
658
|
+
"DELETE FROM _unique_constraints WHERE model_name = ? AND constraint_name = ?",
|
|
659
|
+
[modelName, constraintName]
|
|
660
|
+
);
|
|
661
|
+
const indexName = `idx_uc_${modelName.toLowerCase()}_${constraintName.toLowerCase()}`;
|
|
662
|
+
this.execSqlSync(`DROP INDEX IF EXISTS ${indexName}`);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* List composite unique constraints, optionally filtered by model.
|
|
666
|
+
*/
|
|
667
|
+
listUniqueConstraints(modelName) {
|
|
668
|
+
const sql = modelName ? "SELECT model_name, constraint_name, fields_json, created_at FROM _unique_constraints WHERE model_name = ?" : "SELECT model_name, constraint_name, fields_json, created_at FROM _unique_constraints";
|
|
669
|
+
const params = modelName ? [modelName] : void 0;
|
|
670
|
+
const rows = this.execSqlSync(sql, params);
|
|
671
|
+
return rows.map((row) => ({
|
|
672
|
+
model_name: row.model_name,
|
|
673
|
+
constraint_name: row.constraint_name,
|
|
674
|
+
fields: JSON.parse(row.fields_json),
|
|
675
|
+
created_at: row.created_at
|
|
676
|
+
}));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Check all unique constraints for a model before saving.
|
|
680
|
+
* Returns null if no violation, or an error message string if violated.
|
|
681
|
+
*/
|
|
682
|
+
checkUniqueConstraints(modelName, id, data) {
|
|
683
|
+
const uniqueIndexes = this.getUniqueIndexes(modelName);
|
|
684
|
+
for (const idx of uniqueIndexes) {
|
|
685
|
+
assertValidIdentifier(idx.field_name, "checkUniqueConstraints field_name");
|
|
686
|
+
const value = data[idx.field_name];
|
|
687
|
+
if (value === null || value === void 0) continue;
|
|
688
|
+
const conflicts = this.execSqlSync(
|
|
689
|
+
`SELECT _id FROM records WHERE _type = ? AND json_extract(_data, '$.${idx.field_name}') = ? AND _id != ? LIMIT 1`,
|
|
690
|
+
[modelName, value, id]
|
|
691
|
+
);
|
|
692
|
+
if (conflicts.length > 0) {
|
|
693
|
+
return `Unique constraint violated on field '${idx.field_name}' for model '${modelName}'. Value '${String(value).substring(0, 50)}' already exists on record '${conflicts[0]._id}'.`;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const composites = this.listUniqueConstraints(modelName);
|
|
697
|
+
for (const constraint of composites) {
|
|
698
|
+
for (const f of constraint.fields) {
|
|
699
|
+
assertValidIdentifier(f, "checkUniqueConstraints composite field");
|
|
700
|
+
}
|
|
701
|
+
const values = constraint.fields.map((f) => data[f]);
|
|
702
|
+
if (values.some((v) => v === null || v === void 0)) continue;
|
|
703
|
+
const conditions = constraint.fields.map((f) => `json_extract(_data, '$.${f}') = ?`).join(" AND ");
|
|
704
|
+
const conflicts = this.execSqlSync(
|
|
705
|
+
`SELECT _id FROM records WHERE _type = ? AND ${conditions} AND _id != ? LIMIT 1`,
|
|
706
|
+
[modelName, ...values, id]
|
|
707
|
+
);
|
|
708
|
+
if (conflicts.length > 0) {
|
|
709
|
+
return `Unique constraint '${constraint.constraint_name}' violated for model '${modelName}' on fields [${constraint.fields.join(", ")}]. Values already exist on record '${conflicts[0]._id}'.`;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Check if a record exists (used by hooks to determine isNew).
|
|
716
|
+
*/
|
|
717
|
+
recordExists(modelName, id) {
|
|
718
|
+
const rows = this.execSqlSync(
|
|
719
|
+
"SELECT 1 FROM records WHERE _type = ? AND _id = ? LIMIT 1",
|
|
720
|
+
[modelName, id]
|
|
721
|
+
);
|
|
722
|
+
return rows.length > 0;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Execute SQL asynchronously.
|
|
726
|
+
* Wraps the synchronous DO SQLite API.
|
|
727
|
+
*/
|
|
728
|
+
async execSql(sql, params) {
|
|
729
|
+
return this.execSqlSync(sql, params);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Execute SQL synchronously.
|
|
733
|
+
* This is the native mode for DO SQLite.
|
|
734
|
+
*/
|
|
735
|
+
execSqlSync(sql, params) {
|
|
736
|
+
try {
|
|
737
|
+
const cursor = params ? this.sql.exec(sql, ...params) : this.sql.exec(sql);
|
|
738
|
+
return cursor.toArray();
|
|
739
|
+
} catch (error) {
|
|
740
|
+
console.error("SQL execution error:", error);
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Transaction support using DO's transactionSync.
|
|
746
|
+
*/
|
|
747
|
+
async withTransaction(callback) {
|
|
748
|
+
const ops = {
|
|
749
|
+
insert: async (modelName, data) => {
|
|
750
|
+
await this.insert(modelName, data);
|
|
751
|
+
},
|
|
752
|
+
delete: async (modelName, id) => {
|
|
753
|
+
await this.delete(modelName, id);
|
|
754
|
+
},
|
|
755
|
+
query: async (sql, params) => {
|
|
756
|
+
return this.execSqlSync(sql, params);
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
return callback(ops);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Run operations within a synchronous transaction.
|
|
763
|
+
*/
|
|
764
|
+
transactionSync(callback) {
|
|
765
|
+
return this.storage.transactionSync(callback);
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Clean up resources.
|
|
769
|
+
*/
|
|
770
|
+
async destroy() {
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Insert a record with StringSet support.
|
|
774
|
+
*/
|
|
775
|
+
async insertWithStringSets(modelName, data, stringSets) {
|
|
776
|
+
this.storage.transactionSync(() => {
|
|
777
|
+
const { id, ...fieldsForJson } = data;
|
|
778
|
+
const jsonFields = { ...fieldsForJson, ...stringSets };
|
|
779
|
+
const dataJson = JSON.stringify(jsonFields);
|
|
780
|
+
this.sql.exec(
|
|
781
|
+
"INSERT OR REPLACE INTO records (_id, _type, _data) VALUES (?, ?, ?)",
|
|
782
|
+
id,
|
|
783
|
+
modelName,
|
|
784
|
+
dataJson
|
|
785
|
+
);
|
|
786
|
+
this.sql.exec(
|
|
787
|
+
"DELETE FROM stringset_index WHERE _type = ? AND _record_id = ?",
|
|
788
|
+
modelName,
|
|
789
|
+
id
|
|
790
|
+
);
|
|
791
|
+
for (const [fieldName, values] of Object.entries(stringSets)) {
|
|
792
|
+
for (const value of values) {
|
|
793
|
+
this.sql.exec(
|
|
794
|
+
"INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
|
|
795
|
+
id,
|
|
796
|
+
modelName,
|
|
797
|
+
fieldName,
|
|
798
|
+
value
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Add values to StringSet fields without replacing the entire set.
|
|
806
|
+
* Also updates the arrays stored in data_json.
|
|
807
|
+
*/
|
|
808
|
+
addToStringSets(modelName, id, sets) {
|
|
809
|
+
this.storage.transactionSync(() => {
|
|
810
|
+
this.addToStringSetsRaw(modelName, id, sets);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Non-transactional core of addToStringSets.
|
|
815
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
816
|
+
* @internal
|
|
817
|
+
*/
|
|
818
|
+
addToStringSetsRaw(modelName, id, sets) {
|
|
819
|
+
for (const [field, values] of Object.entries(sets)) {
|
|
820
|
+
for (const value of values) {
|
|
821
|
+
this.sql.exec(
|
|
822
|
+
"INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
|
|
823
|
+
id,
|
|
824
|
+
modelName,
|
|
825
|
+
field,
|
|
826
|
+
value
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
this._syncStringSetsToJson(modelName, id, Object.keys(sets));
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Remove values from StringSet fields without replacing the entire set.
|
|
834
|
+
* Also updates the arrays stored in data_json.
|
|
835
|
+
*/
|
|
836
|
+
removeFromStringSets(modelName, id, sets) {
|
|
837
|
+
this.storage.transactionSync(() => {
|
|
838
|
+
this.removeFromStringSetsRaw(modelName, id, sets);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Non-transactional core of removeFromStringSets.
|
|
843
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
844
|
+
* @internal
|
|
845
|
+
*/
|
|
846
|
+
removeFromStringSetsRaw(modelName, id, sets) {
|
|
847
|
+
for (const [field, values] of Object.entries(sets)) {
|
|
848
|
+
for (const value of values) {
|
|
849
|
+
this.sql.exec(
|
|
850
|
+
"DELETE FROM stringset_index WHERE _record_id = ? AND _type = ? AND field = ? AND value = ?",
|
|
851
|
+
id,
|
|
852
|
+
modelName,
|
|
853
|
+
field,
|
|
854
|
+
value
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
this._syncStringSetsToJson(modelName, id, Object.keys(sets));
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Sync stringset_index state back into data_json arrays for the given fields.
|
|
862
|
+
* @internal
|
|
863
|
+
*/
|
|
864
|
+
_syncStringSetsToJson(modelName, id, fields) {
|
|
865
|
+
const rows = this.execSqlSync(
|
|
866
|
+
"SELECT _data FROM records WHERE _id = ? AND _type = ?",
|
|
867
|
+
[id, modelName]
|
|
868
|
+
);
|
|
869
|
+
if (rows.length === 0) return;
|
|
870
|
+
const data = JSON.parse(rows[0]._data);
|
|
871
|
+
for (const field of fields) {
|
|
872
|
+
const ssRows = this.execSqlSync(
|
|
873
|
+
"SELECT value FROM stringset_index WHERE _record_id = ? AND _type = ? AND field = ? ORDER BY value",
|
|
874
|
+
[id, modelName, field]
|
|
875
|
+
);
|
|
876
|
+
data[field] = ssRows.map((r) => r.value);
|
|
877
|
+
}
|
|
878
|
+
this.sql.exec(
|
|
879
|
+
"UPDATE records SET _data = ? WHERE _id = ? AND _type = ?",
|
|
880
|
+
JSON.stringify(data),
|
|
881
|
+
id,
|
|
882
|
+
modelName
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Patch a record: merge provided fields into existing data_json.
|
|
887
|
+
* Only the specified fields are updated; all other fields are preserved.
|
|
888
|
+
* If stringSets are provided, those StringSet fields are fully replaced.
|
|
889
|
+
* Returns false if the record does not exist.
|
|
890
|
+
*/
|
|
891
|
+
patchRecord(modelName, id, fields, stringSets) {
|
|
892
|
+
let found = true;
|
|
893
|
+
this.storage.transactionSync(() => {
|
|
894
|
+
found = this.patchRecordRaw(modelName, id, fields, stringSets);
|
|
895
|
+
});
|
|
896
|
+
return found;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Non-transactional core of patchRecord.
|
|
900
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
901
|
+
* @internal
|
|
902
|
+
*/
|
|
903
|
+
patchRecordRaw(modelName, id, fields, stringSets) {
|
|
904
|
+
const rows = this.execSqlSync(
|
|
905
|
+
"SELECT _data FROM records WHERE _id = ? AND _type = ?",
|
|
906
|
+
[id, modelName]
|
|
907
|
+
);
|
|
908
|
+
if (rows.length === 0) {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
const existing = JSON.parse(rows[0]._data);
|
|
912
|
+
const { id: _stripId, ...patchFields } = fields;
|
|
913
|
+
const merged = { ...existing, ...patchFields };
|
|
914
|
+
if (stringSets) {
|
|
915
|
+
for (const [field, values] of Object.entries(stringSets)) {
|
|
916
|
+
merged[field] = values;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
this.sql.exec(
|
|
920
|
+
"UPDATE records SET _data = ? WHERE _id = ? AND _type = ?",
|
|
921
|
+
JSON.stringify(merged),
|
|
922
|
+
id,
|
|
923
|
+
modelName
|
|
924
|
+
);
|
|
925
|
+
if (stringSets) {
|
|
926
|
+
for (const [fieldName, values] of Object.entries(stringSets)) {
|
|
927
|
+
this.sql.exec(
|
|
928
|
+
"DELETE FROM stringset_index WHERE _record_id = ? AND _type = ? AND field = ?",
|
|
929
|
+
id,
|
|
930
|
+
modelName,
|
|
931
|
+
fieldName
|
|
932
|
+
);
|
|
933
|
+
for (const value of values) {
|
|
934
|
+
this.sql.exec(
|
|
935
|
+
"INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
|
|
936
|
+
id,
|
|
937
|
+
modelName,
|
|
938
|
+
fieldName,
|
|
939
|
+
value
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Atomically increment/decrement numeric fields on a record.
|
|
948
|
+
* Each key in `fields` is a field name and its value is the delta.
|
|
949
|
+
* Missing fields are initialised to 0 before adding the delta.
|
|
950
|
+
* Returns the new values, or null if the record doesn't exist.
|
|
951
|
+
*/
|
|
952
|
+
incrementFields(modelName, id, fields) {
|
|
953
|
+
let result = null;
|
|
954
|
+
this.storage.transactionSync(() => {
|
|
955
|
+
result = this.incrementFieldsRaw(modelName, id, fields);
|
|
956
|
+
});
|
|
957
|
+
return result;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Non-transactional core of incrementFields.
|
|
961
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
962
|
+
* @internal
|
|
963
|
+
*/
|
|
964
|
+
incrementFieldsRaw(modelName, id, fields) {
|
|
965
|
+
const rows = this.execSqlSync(
|
|
966
|
+
"SELECT _data FROM records WHERE _id = ? AND _type = ?",
|
|
967
|
+
[id, modelName]
|
|
968
|
+
);
|
|
969
|
+
if (rows.length === 0) return null;
|
|
970
|
+
const data = JSON.parse(rows[0]._data);
|
|
971
|
+
const newValues = {};
|
|
972
|
+
for (const [field, delta] of Object.entries(fields)) {
|
|
973
|
+
const current = typeof data[field] === "number" ? data[field] : 0;
|
|
974
|
+
data[field] = current + delta;
|
|
975
|
+
newValues[field] = data[field];
|
|
976
|
+
}
|
|
977
|
+
this.sql.exec(
|
|
978
|
+
"UPDATE records SET _data = ? WHERE _id = ? AND _type = ?",
|
|
979
|
+
JSON.stringify(data),
|
|
980
|
+
id,
|
|
981
|
+
modelName
|
|
982
|
+
);
|
|
983
|
+
return newValues;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Delete a record and its StringSet values atomically.
|
|
987
|
+
*/
|
|
988
|
+
async deleteWithStringSets(modelName, id) {
|
|
989
|
+
this.storage.transactionSync(() => {
|
|
990
|
+
this.sql.exec("DELETE FROM records WHERE _type = ? AND _id = ?", modelName, id);
|
|
991
|
+
this.sql.exec(
|
|
992
|
+
"DELETE FROM stringset_index WHERE _type = ? AND _record_id = ?",
|
|
993
|
+
modelName,
|
|
994
|
+
id
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Track field names and inferred types for a model based on written data.
|
|
1000
|
+
* Uses INSERT OR IGNORE so the first-seen type wins.
|
|
1001
|
+
*/
|
|
1002
|
+
trackModelFields(modelName, data) {
|
|
1003
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1004
|
+
if (key === "id" || value === null || value === void 0) {
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
let inferredType;
|
|
1008
|
+
if (typeof value === "number") {
|
|
1009
|
+
inferredType = "number";
|
|
1010
|
+
} else if (typeof value === "boolean") {
|
|
1011
|
+
inferredType = "boolean";
|
|
1012
|
+
} else if (Array.isArray(value)) {
|
|
1013
|
+
inferredType = "array";
|
|
1014
|
+
} else if (typeof value === "object") {
|
|
1015
|
+
inferredType = "object";
|
|
1016
|
+
} else {
|
|
1017
|
+
inferredType = "string";
|
|
1018
|
+
}
|
|
1019
|
+
this.sql.exec(
|
|
1020
|
+
"INSERT OR IGNORE INTO _model_fields (model_name, field_name, inferred_type) VALUES (?, ?, ?)",
|
|
1021
|
+
modelName,
|
|
1022
|
+
key,
|
|
1023
|
+
inferredType
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Get tracked fields for a model, or all models if no name given.
|
|
1029
|
+
*/
|
|
1030
|
+
getModelFields(modelName) {
|
|
1031
|
+
if (modelName) {
|
|
1032
|
+
return this.execSqlSync(
|
|
1033
|
+
"SELECT model_name, field_name, inferred_type, first_seen_at FROM _model_fields WHERE model_name = ? ORDER BY field_name",
|
|
1034
|
+
[modelName]
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return this.execSqlSync(
|
|
1038
|
+
"SELECT model_name, field_name, inferred_type, first_seen_at FROM _model_fields ORDER BY model_name, field_name"
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
// src/types/queryTypes.ts
|
|
1044
|
+
var DocumentQueryError = class _DocumentQueryError extends Error {
|
|
1045
|
+
constructor(message) {
|
|
1046
|
+
super(message);
|
|
1047
|
+
this.name = "DocumentQueryError";
|
|
1048
|
+
Object.setPrototypeOf(this, _DocumentQueryError.prototype);
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
var InvalidOperatorError = class _InvalidOperatorError extends DocumentQueryError {
|
|
1052
|
+
field;
|
|
1053
|
+
operator;
|
|
1054
|
+
fieldType;
|
|
1055
|
+
constructor(message, field, operator, fieldType) {
|
|
1056
|
+
super(message);
|
|
1057
|
+
this.name = "InvalidOperatorError";
|
|
1058
|
+
this.field = field;
|
|
1059
|
+
this.operator = operator;
|
|
1060
|
+
this.fieldType = fieldType;
|
|
1061
|
+
Object.setPrototypeOf(this, _InvalidOperatorError.prototype);
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
var InvalidFieldError = class _InvalidFieldError extends DocumentQueryError {
|
|
1065
|
+
field;
|
|
1066
|
+
modelName;
|
|
1067
|
+
constructor(message, field, modelName) {
|
|
1068
|
+
super(message);
|
|
1069
|
+
this.name = "InvalidFieldError";
|
|
1070
|
+
this.field = field;
|
|
1071
|
+
this.modelName = modelName;
|
|
1072
|
+
Object.setPrototypeOf(this, _InvalidFieldError.prototype);
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
var InvalidCursorError = class _InvalidCursorError extends DocumentQueryError {
|
|
1076
|
+
cursor;
|
|
1077
|
+
constructor(message, cursor) {
|
|
1078
|
+
super(message);
|
|
1079
|
+
this.name = "InvalidCursorError";
|
|
1080
|
+
this.cursor = cursor;
|
|
1081
|
+
Object.setPrototypeOf(this, _InvalidCursorError.prototype);
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
// src/query/CursorManager.ts
|
|
1086
|
+
var base64Encode = (str) => {
|
|
1087
|
+
if (typeof btoa !== "undefined") {
|
|
1088
|
+
return btoa(str);
|
|
1089
|
+
} else if (typeof Buffer !== "undefined") {
|
|
1090
|
+
return Buffer.from(str, "utf-8").toString("base64");
|
|
1091
|
+
} else {
|
|
1092
|
+
throw new Error("No base64 encoding available");
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
var base64Decode = (str) => {
|
|
1096
|
+
if (typeof atob !== "undefined") {
|
|
1097
|
+
return atob(str);
|
|
1098
|
+
} else if (typeof Buffer !== "undefined") {
|
|
1099
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
1100
|
+
} else {
|
|
1101
|
+
throw new Error("No base64 decoding available");
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
var CursorManager = class {
|
|
1105
|
+
/**
|
|
1106
|
+
* Encode cursor data to base64 string
|
|
1107
|
+
*/
|
|
1108
|
+
static encodeCursor(cursorData) {
|
|
1109
|
+
try {
|
|
1110
|
+
const jsonString = JSON.stringify(cursorData);
|
|
1111
|
+
return base64Encode(jsonString);
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
throw new InvalidCursorError(
|
|
1114
|
+
`Failed to encode cursor: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1115
|
+
JSON.stringify(cursorData)
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Decode base64 cursor string to cursor data
|
|
1121
|
+
*/
|
|
1122
|
+
static decodeCursor(cursor) {
|
|
1123
|
+
try {
|
|
1124
|
+
const jsonString = base64Decode(cursor);
|
|
1125
|
+
const parsed = JSON.parse(jsonString);
|
|
1126
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1127
|
+
throw new Error("Cursor must be an object");
|
|
1128
|
+
}
|
|
1129
|
+
if (!parsed.values || typeof parsed.values !== "object") {
|
|
1130
|
+
throw new Error("Cursor must have values object");
|
|
1131
|
+
}
|
|
1132
|
+
if (!Array.isArray(parsed.sortFields)) {
|
|
1133
|
+
throw new Error("Cursor must have sortFields array");
|
|
1134
|
+
}
|
|
1135
|
+
if (parsed.direction !== 1 && parsed.direction !== -1) {
|
|
1136
|
+
throw new Error("Cursor direction must be 1 or -1");
|
|
1137
|
+
}
|
|
1138
|
+
return parsed;
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
throw new InvalidCursorError(
|
|
1141
|
+
`Failed to decode cursor: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1142
|
+
cursor
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Generate cursor from a record and sort specification
|
|
1148
|
+
*/
|
|
1149
|
+
static generateCursor(record, sortFields, direction) {
|
|
1150
|
+
const values = {};
|
|
1151
|
+
for (const field of sortFields) {
|
|
1152
|
+
if (record.hasOwnProperty(field)) {
|
|
1153
|
+
values[field] = record[field];
|
|
1154
|
+
} else {
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
`Cannot generate cursor: record missing sort field '${field}'`
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const cursorData = {
|
|
1161
|
+
values,
|
|
1162
|
+
sortFields,
|
|
1163
|
+
direction
|
|
1164
|
+
};
|
|
1165
|
+
return this.encodeCursor(cursorData);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Build SQL pagination conditions based on cursor
|
|
1169
|
+
* Uses lexicographic ordering for stable pagination with multiple sort fields
|
|
1170
|
+
*/
|
|
1171
|
+
static buildPaginationConditions(cursor, currentSortFields, sortDirections, requestedDirection, fieldFormatter = (field) => field) {
|
|
1172
|
+
if (!this.arraysEqual(cursor.sortFields, currentSortFields)) {
|
|
1173
|
+
throw new InvalidCursorError(
|
|
1174
|
+
`Cursor sort fields [${cursor.sortFields.join(
|
|
1175
|
+
", "
|
|
1176
|
+
)}] don't match query sort fields [${currentSortFields.join(", ")}]`,
|
|
1177
|
+
JSON.stringify(cursor)
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
const conditions = [];
|
|
1181
|
+
const params = [];
|
|
1182
|
+
const sortFields = cursor.sortFields;
|
|
1183
|
+
const direction = requestedDirection;
|
|
1184
|
+
for (let i = 0; i < sortFields.length; i++) {
|
|
1185
|
+
const fieldConditions = [];
|
|
1186
|
+
const fieldParams = [];
|
|
1187
|
+
for (let j = 0; j < i; j++) {
|
|
1188
|
+
const field = sortFields[j];
|
|
1189
|
+
const value = cursor.values[field];
|
|
1190
|
+
fieldConditions.push(`${fieldFormatter(field)} = ?`);
|
|
1191
|
+
fieldParams.push(value);
|
|
1192
|
+
}
|
|
1193
|
+
const currentField = sortFields[i];
|
|
1194
|
+
const currentValue = cursor.values[currentField];
|
|
1195
|
+
const fieldSortDir = sortDirections[i] ?? 1;
|
|
1196
|
+
const forwardOp = fieldSortDir === 1 ? ">" : "<";
|
|
1197
|
+
const operator = direction === 1 ? forwardOp : forwardOp === ">" ? "<" : ">";
|
|
1198
|
+
fieldConditions.push(`${fieldFormatter(currentField)} ${operator} ?`);
|
|
1199
|
+
fieldParams.push(currentValue);
|
|
1200
|
+
const levelCondition = fieldConditions.join(" AND ");
|
|
1201
|
+
conditions.push(`(${levelCondition})`);
|
|
1202
|
+
params.push(...fieldParams);
|
|
1203
|
+
}
|
|
1204
|
+
const sql = `(${conditions.join(" OR ")})`;
|
|
1205
|
+
return { sql, params };
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Extract sort fields from sort specification, ensuring 'id' is always included for stability
|
|
1209
|
+
*/
|
|
1210
|
+
static extractSortFields(sort) {
|
|
1211
|
+
const fields = [];
|
|
1212
|
+
if (sort && Object.keys(sort).length > 0) {
|
|
1213
|
+
fields.push(...Object.keys(sort));
|
|
1214
|
+
}
|
|
1215
|
+
if (!fields.includes("id")) {
|
|
1216
|
+
fields.push("id");
|
|
1217
|
+
}
|
|
1218
|
+
return fields;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Build ORDER BY clause from sort specification
|
|
1222
|
+
*/
|
|
1223
|
+
static buildOrderClause(sort, fieldFormatter = (field) => field) {
|
|
1224
|
+
const fields = this.extractSortFields(sort);
|
|
1225
|
+
const clauses = [];
|
|
1226
|
+
const directions = [];
|
|
1227
|
+
if (sort && Object.keys(sort).length > 0) {
|
|
1228
|
+
for (const [field, dir] of Object.entries(sort)) {
|
|
1229
|
+
const sqlDirection = dir === 1 ? "ASC" : "DESC";
|
|
1230
|
+
clauses.push(`${fieldFormatter(field)} ${sqlDirection}`);
|
|
1231
|
+
directions.push(dir);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (!sort || !sort.hasOwnProperty("id")) {
|
|
1235
|
+
clauses.push(`${fieldFormatter("id")} ASC`);
|
|
1236
|
+
directions.push(1);
|
|
1237
|
+
} else if (sort && sort.hasOwnProperty("id")) {
|
|
1238
|
+
directions.push(sort["id"]);
|
|
1239
|
+
}
|
|
1240
|
+
return {
|
|
1241
|
+
sql: clauses.join(", "),
|
|
1242
|
+
fields,
|
|
1243
|
+
directions
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Determine if there are more results available for pagination
|
|
1248
|
+
*/
|
|
1249
|
+
static hasMoreResults(requestedLimit, actualResultCount) {
|
|
1250
|
+
if (!requestedLimit || requestedLimit <= 0) {
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
return actualResultCount >= requestedLimit;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Generate next and previous cursors from result set
|
|
1257
|
+
*/
|
|
1258
|
+
static generateResultCursors(results, sortFields, _requestedDirection, hasMore, isFirstPage = false) {
|
|
1259
|
+
if (results.length === 0) {
|
|
1260
|
+
return {};
|
|
1261
|
+
}
|
|
1262
|
+
const cursors = {};
|
|
1263
|
+
if (hasMore && results.length > 0) {
|
|
1264
|
+
const lastResult = results[results.length - 1];
|
|
1265
|
+
cursors.nextCursor = this.generateCursor(lastResult, sortFields, 1);
|
|
1266
|
+
}
|
|
1267
|
+
if (results.length > 0 && !isFirstPage) {
|
|
1268
|
+
const firstResult = results[0];
|
|
1269
|
+
cursors.prevCursor = this.generateCursor(firstResult, sortFields, -1);
|
|
1270
|
+
}
|
|
1271
|
+
return cursors;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Utility to compare arrays for equality
|
|
1275
|
+
*/
|
|
1276
|
+
static arraysEqual(a, b) {
|
|
1277
|
+
if (a.length !== b.length) return false;
|
|
1278
|
+
return a.every((val, index) => val === b[index]);
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
// src/utils/patterns.ts
|
|
1283
|
+
function escapeLikeLiteral(input) {
|
|
1284
|
+
return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
1285
|
+
}
|
|
1286
|
+
function buildLikePattern(value, mode) {
|
|
1287
|
+
const trimmed = (value ?? "").trim();
|
|
1288
|
+
if (trimmed.length === 0) return null;
|
|
1289
|
+
if (trimmed.length > 1024) {
|
|
1290
|
+
throw new Error("substring value exceeds 1024 characters");
|
|
1291
|
+
}
|
|
1292
|
+
const escaped = escapeLikeLiteral(trimmed);
|
|
1293
|
+
switch (mode) {
|
|
1294
|
+
case "startsWith":
|
|
1295
|
+
return `${escaped}%`;
|
|
1296
|
+
case "endsWith":
|
|
1297
|
+
return `%${escaped}`;
|
|
1298
|
+
case "containsText":
|
|
1299
|
+
return `%${escaped}%`;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
function shouldLogLikeEscapes() {
|
|
1303
|
+
try {
|
|
1304
|
+
if (typeof process !== "undefined" && process.env) {
|
|
1305
|
+
return !!process.env.JS_BAO_DEBUG_LIKE_ESCAPES;
|
|
1306
|
+
}
|
|
1307
|
+
} catch {
|
|
1308
|
+
}
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/query/JsonQueryTranslator.ts
|
|
1313
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["id", "type"]);
|
|
1314
|
+
var JsonQueryTranslator = class {
|
|
1315
|
+
modelName;
|
|
1316
|
+
schema;
|
|
1317
|
+
options;
|
|
1318
|
+
fieldSqlCache;
|
|
1319
|
+
constructor(modelName, schema, options = { includeDocId: false }) {
|
|
1320
|
+
this.modelName = modelName;
|
|
1321
|
+
this.schema = schema;
|
|
1322
|
+
this.options = options;
|
|
1323
|
+
this.fieldSqlCache = /* @__PURE__ */ new Map();
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Get SQL expression for a field.
|
|
1327
|
+
* System fields (id, type) map to internal columns (_id, _type);
|
|
1328
|
+
* others use json_extract(_data, ...).
|
|
1329
|
+
* When no schema is provided, any field is accepted (schemaless mode).
|
|
1330
|
+
*/
|
|
1331
|
+
getFieldSql(fieldName) {
|
|
1332
|
+
if (!this.fieldSqlCache.has(fieldName)) {
|
|
1333
|
+
if (SYSTEM_FIELDS.has(fieldName)) {
|
|
1334
|
+
const internalName = `_${fieldName}`;
|
|
1335
|
+
this.fieldSqlCache.set(fieldName, quoteIdentifier(internalName));
|
|
1336
|
+
} else {
|
|
1337
|
+
assertValidIdentifier(fieldName, "query field");
|
|
1338
|
+
this.fieldSqlCache.set(
|
|
1339
|
+
fieldName,
|
|
1340
|
+
`json_extract(_data, '$.${fieldName}')`
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return this.fieldSqlCache.get(fieldName);
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Translate document filter and options to SQL for find operations
|
|
1348
|
+
*/
|
|
1349
|
+
translateFind(filter, options) {
|
|
1350
|
+
if (options?.projection) {
|
|
1351
|
+
this.validateProjection(options.projection);
|
|
1352
|
+
}
|
|
1353
|
+
const whereClause = this.translateFilter(filter);
|
|
1354
|
+
const orderClause = CursorManager.buildOrderClause(
|
|
1355
|
+
options?.sort,
|
|
1356
|
+
(field) => this.getFieldSql(field)
|
|
1357
|
+
);
|
|
1358
|
+
const limitClause = this.buildLimitClause(options);
|
|
1359
|
+
const paginationClause = this.buildPaginationClause(
|
|
1360
|
+
options,
|
|
1361
|
+
orderClause.fields,
|
|
1362
|
+
orderClause.directions
|
|
1363
|
+
);
|
|
1364
|
+
const selectClause = this.buildSelectClause(options?.projection);
|
|
1365
|
+
let sql = `SELECT ${selectClause} FROM records`;
|
|
1366
|
+
const params = [];
|
|
1367
|
+
const conditions = [`${quoteIdentifier("_type")} = ?`];
|
|
1368
|
+
params.push(this.modelName);
|
|
1369
|
+
if (whereClause.sql) {
|
|
1370
|
+
conditions.push(whereClause.sql);
|
|
1371
|
+
params.push(...whereClause.params);
|
|
1372
|
+
}
|
|
1373
|
+
if (paginationClause.sql) {
|
|
1374
|
+
conditions.push(paginationClause.sql);
|
|
1375
|
+
params.push(...paginationClause.params);
|
|
1376
|
+
}
|
|
1377
|
+
if (this.options.includeDocId) {
|
|
1378
|
+
const documentClause = this.buildDocumentClause(options?.documents);
|
|
1379
|
+
if (documentClause) {
|
|
1380
|
+
conditions.push(documentClause.sql);
|
|
1381
|
+
params.push(...documentClause.params);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
1385
|
+
if (orderClause.sql) {
|
|
1386
|
+
sql += ` ORDER BY ${orderClause.sql}`;
|
|
1387
|
+
}
|
|
1388
|
+
if (limitClause.sql) {
|
|
1389
|
+
sql += ` LIMIT ${limitClause.sql}`;
|
|
1390
|
+
}
|
|
1391
|
+
return {
|
|
1392
|
+
sql,
|
|
1393
|
+
params,
|
|
1394
|
+
sortFields: orderClause.fields,
|
|
1395
|
+
sortDirections: orderClause.directions
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Translate document filter to SQL for count operations
|
|
1400
|
+
*/
|
|
1401
|
+
translateCount(filter, options) {
|
|
1402
|
+
const whereClause = this.translateFilter(filter);
|
|
1403
|
+
const conditions = [`${quoteIdentifier("_type")} = ?`];
|
|
1404
|
+
const params = [this.modelName];
|
|
1405
|
+
if (whereClause.sql) {
|
|
1406
|
+
conditions.push(whereClause.sql);
|
|
1407
|
+
params.push(...whereClause.params);
|
|
1408
|
+
}
|
|
1409
|
+
if (this.options.includeDocId) {
|
|
1410
|
+
const documentClause = this.buildDocumentClause(options?.documents);
|
|
1411
|
+
if (documentClause) {
|
|
1412
|
+
conditions.push(documentClause.sql);
|
|
1413
|
+
params.push(...documentClause.params);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const sql = `SELECT COUNT(*) as count FROM records WHERE ${conditions.join(
|
|
1417
|
+
" AND "
|
|
1418
|
+
)}`;
|
|
1419
|
+
return { sql, params };
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Translate document filter to SQL WHERE clause.
|
|
1423
|
+
* Returns the conditions and params without the type = ? prefix.
|
|
1424
|
+
*/
|
|
1425
|
+
translateFilter(filter) {
|
|
1426
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
1427
|
+
return { sql: "", params: [] };
|
|
1428
|
+
}
|
|
1429
|
+
const conditions = [];
|
|
1430
|
+
const params = [];
|
|
1431
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1432
|
+
if (key === "$and") {
|
|
1433
|
+
const andClause = this.translateLogicalOperator(
|
|
1434
|
+
"AND",
|
|
1435
|
+
value
|
|
1436
|
+
);
|
|
1437
|
+
if (andClause.sql) {
|
|
1438
|
+
conditions.push(`(${andClause.sql})`);
|
|
1439
|
+
params.push(...andClause.params);
|
|
1440
|
+
}
|
|
1441
|
+
} else if (key === "$or") {
|
|
1442
|
+
const orClause = this.translateLogicalOperator(
|
|
1443
|
+
"OR",
|
|
1444
|
+
value
|
|
1445
|
+
);
|
|
1446
|
+
if (orClause.sql) {
|
|
1447
|
+
conditions.push(`(${orClause.sql})`);
|
|
1448
|
+
params.push(...orClause.params);
|
|
1449
|
+
}
|
|
1450
|
+
} else {
|
|
1451
|
+
const fieldClause = this.translateFieldCondition(key, value);
|
|
1452
|
+
if (fieldClause.sql) {
|
|
1453
|
+
conditions.push(fieldClause.sql);
|
|
1454
|
+
params.push(...fieldClause.params);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return {
|
|
1459
|
+
sql: conditions.join(" AND "),
|
|
1460
|
+
params
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Translate logical operators ($and, $or)
|
|
1465
|
+
*/
|
|
1466
|
+
translateLogicalOperator(operator, filters) {
|
|
1467
|
+
if (!Array.isArray(filters) || filters.length === 0) {
|
|
1468
|
+
return { sql: "", params: [] };
|
|
1469
|
+
}
|
|
1470
|
+
const clauses = [];
|
|
1471
|
+
const params = [];
|
|
1472
|
+
for (const filter of filters) {
|
|
1473
|
+
const clause = this.translateFilter(filter);
|
|
1474
|
+
if (clause.sql) {
|
|
1475
|
+
clauses.push(clause.sql);
|
|
1476
|
+
params.push(...clause.params);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (clauses.length === 0) {
|
|
1480
|
+
return { sql: "", params: [] };
|
|
1481
|
+
}
|
|
1482
|
+
return {
|
|
1483
|
+
sql: clauses.join(` ${operator} `),
|
|
1484
|
+
params
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Translate field condition to SQL
|
|
1489
|
+
*/
|
|
1490
|
+
translateFieldCondition(fieldName, condition) {
|
|
1491
|
+
const fieldSql = this.getFieldSql(fieldName);
|
|
1492
|
+
if (condition === null || condition === void 0) {
|
|
1493
|
+
return { sql: `${fieldSql} IS NULL`, params: [] };
|
|
1494
|
+
}
|
|
1495
|
+
if (this.isPrimitiveValue(condition)) {
|
|
1496
|
+
this.validateFieldValue(fieldName, condition);
|
|
1497
|
+
return {
|
|
1498
|
+
sql: `${fieldSql} = ?`,
|
|
1499
|
+
params: [this.convertValueForSQLite(condition)]
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
if (typeof condition === "object" && !Array.isArray(condition)) {
|
|
1503
|
+
return this.translateFieldOperators(fieldName, condition);
|
|
1504
|
+
}
|
|
1505
|
+
if (Array.isArray(condition)) {
|
|
1506
|
+
this.validateArrayValues(fieldName, condition);
|
|
1507
|
+
const placeholders = condition.map(() => "?").join(",");
|
|
1508
|
+
const convertedValues = condition.map(
|
|
1509
|
+
(v) => this.convertValueForSQLite(v)
|
|
1510
|
+
);
|
|
1511
|
+
return {
|
|
1512
|
+
sql: `${fieldSql} IN (${placeholders})`,
|
|
1513
|
+
params: convertedValues
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
throw new InvalidOperatorError(
|
|
1517
|
+
`Unsupported condition type for field ${fieldName}`,
|
|
1518
|
+
fieldName,
|
|
1519
|
+
"unknown"
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Translate field operators to SQL
|
|
1524
|
+
*/
|
|
1525
|
+
translateFieldOperators(fieldName, operators) {
|
|
1526
|
+
const conditions = [];
|
|
1527
|
+
const params = [];
|
|
1528
|
+
const fieldOptions = this.schema?.get(fieldName);
|
|
1529
|
+
const fieldType = fieldOptions?.type;
|
|
1530
|
+
const fieldSql = this.getFieldSql(fieldName);
|
|
1531
|
+
const substringOps = [
|
|
1532
|
+
"$startsWith",
|
|
1533
|
+
"$endsWith",
|
|
1534
|
+
"$containsText"
|
|
1535
|
+
];
|
|
1536
|
+
const presentSubstringOps = substringOps.filter(
|
|
1537
|
+
(op) => Object.prototype.hasOwnProperty.call(operators, op)
|
|
1538
|
+
);
|
|
1539
|
+
if (presentSubstringOps.length > 1) {
|
|
1540
|
+
throw new InvalidOperatorError(
|
|
1541
|
+
`Only one of $startsWith, $endsWith, $containsText may be used per field`,
|
|
1542
|
+
fieldName,
|
|
1543
|
+
presentSubstringOps[1],
|
|
1544
|
+
fieldType
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
if (presentSubstringOps.length === 1) {
|
|
1548
|
+
const op = presentSubstringOps[0];
|
|
1549
|
+
const value = operators[op];
|
|
1550
|
+
if (fieldType && fieldType !== "string" && fieldType !== "stringset") {
|
|
1551
|
+
throw new InvalidOperatorError(
|
|
1552
|
+
`${op} operator is only supported for string and stringset fields, field ${fieldName} is type ${fieldType}`,
|
|
1553
|
+
fieldName,
|
|
1554
|
+
op,
|
|
1555
|
+
fieldType
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
if (typeof value !== "string") {
|
|
1559
|
+
throw new InvalidOperatorError(
|
|
1560
|
+
`${op} operator requires a string value for field ${fieldName}`,
|
|
1561
|
+
fieldName,
|
|
1562
|
+
op,
|
|
1563
|
+
fieldType
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
let pattern;
|
|
1567
|
+
try {
|
|
1568
|
+
const mode = op === "$startsWith" ? "startsWith" : op === "$endsWith" ? "endsWith" : "containsText";
|
|
1569
|
+
pattern = buildLikePattern(value, mode);
|
|
1570
|
+
} catch (e) {
|
|
1571
|
+
throw new InvalidOperatorError(
|
|
1572
|
+
e.message || "invalid substring value",
|
|
1573
|
+
fieldName,
|
|
1574
|
+
op,
|
|
1575
|
+
fieldType
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
if (pattern !== null) {
|
|
1579
|
+
if (shouldLogLikeEscapes()) {
|
|
1580
|
+
try {
|
|
1581
|
+
console.debug(
|
|
1582
|
+
`[Substring] field=${fieldName} op=${op} pattern=${pattern} ci=true`
|
|
1583
|
+
);
|
|
1584
|
+
} catch {
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (fieldType === "stringset") {
|
|
1588
|
+
const likeSql = `EXISTS (SELECT 1 FROM stringset_index WHERE stringset_index._type = ? AND stringset_index.field = ? AND stringset_index._record_id = records._id AND stringset_index.value LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
|
1589
|
+
conditions.push(likeSql);
|
|
1590
|
+
params.push(this.modelName, fieldName, pattern);
|
|
1591
|
+
} else {
|
|
1592
|
+
const likeSql = `${fieldSql} LIKE ? ESCAPE '\\' COLLATE NOCASE`;
|
|
1593
|
+
conditions.push(likeSql);
|
|
1594
|
+
params.push(pattern);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
for (const [operator, value] of Object.entries(operators)) {
|
|
1599
|
+
if (substringOps.includes(operator)) {
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
const opClause = this.translateOperator(fieldName, operator, value);
|
|
1603
|
+
if (opClause.sql) {
|
|
1604
|
+
conditions.push(opClause.sql);
|
|
1605
|
+
params.push(...opClause.params);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
return {
|
|
1609
|
+
sql: conditions.join(" AND "),
|
|
1610
|
+
params
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Translate individual operator to SQL
|
|
1615
|
+
*/
|
|
1616
|
+
translateOperator(fieldName, operator, value) {
|
|
1617
|
+
const fieldOptions = this.schema?.get(fieldName);
|
|
1618
|
+
const fieldType = fieldOptions?.type;
|
|
1619
|
+
const fieldSql = this.getFieldSql(fieldName);
|
|
1620
|
+
switch (operator) {
|
|
1621
|
+
case "$eq":
|
|
1622
|
+
this.validateFieldValue(fieldName, value);
|
|
1623
|
+
return {
|
|
1624
|
+
sql: `${fieldSql} = ?`,
|
|
1625
|
+
params: [this.convertValueForSQLite(value)]
|
|
1626
|
+
};
|
|
1627
|
+
case "$ne":
|
|
1628
|
+
this.validateFieldValue(fieldName, value);
|
|
1629
|
+
return {
|
|
1630
|
+
sql: `${fieldSql} != ?`,
|
|
1631
|
+
params: [this.convertValueForSQLite(value)]
|
|
1632
|
+
};
|
|
1633
|
+
case "$gt":
|
|
1634
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1635
|
+
"id",
|
|
1636
|
+
"string",
|
|
1637
|
+
"number",
|
|
1638
|
+
"date"
|
|
1639
|
+
]);
|
|
1640
|
+
this.validateFieldValue(fieldName, value);
|
|
1641
|
+
return {
|
|
1642
|
+
sql: `${fieldSql} > ?`,
|
|
1643
|
+
params: [this.convertValueForSQLite(value)]
|
|
1644
|
+
};
|
|
1645
|
+
case "$gte":
|
|
1646
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1647
|
+
"id",
|
|
1648
|
+
"string",
|
|
1649
|
+
"number",
|
|
1650
|
+
"date"
|
|
1651
|
+
]);
|
|
1652
|
+
this.validateFieldValue(fieldName, value);
|
|
1653
|
+
return {
|
|
1654
|
+
sql: `${fieldSql} >= ?`,
|
|
1655
|
+
params: [this.convertValueForSQLite(value)]
|
|
1656
|
+
};
|
|
1657
|
+
case "$lt":
|
|
1658
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1659
|
+
"id",
|
|
1660
|
+
"string",
|
|
1661
|
+
"number",
|
|
1662
|
+
"date"
|
|
1663
|
+
]);
|
|
1664
|
+
this.validateFieldValue(fieldName, value);
|
|
1665
|
+
return {
|
|
1666
|
+
sql: `${fieldSql} < ?`,
|
|
1667
|
+
params: [this.convertValueForSQLite(value)]
|
|
1668
|
+
};
|
|
1669
|
+
case "$lte":
|
|
1670
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1671
|
+
"id",
|
|
1672
|
+
"string",
|
|
1673
|
+
"number",
|
|
1674
|
+
"date"
|
|
1675
|
+
]);
|
|
1676
|
+
this.validateFieldValue(fieldName, value);
|
|
1677
|
+
return {
|
|
1678
|
+
sql: `${fieldSql} <= ?`,
|
|
1679
|
+
params: [this.convertValueForSQLite(value)]
|
|
1680
|
+
};
|
|
1681
|
+
case "$in":
|
|
1682
|
+
if (!Array.isArray(value)) {
|
|
1683
|
+
throw new InvalidOperatorError(
|
|
1684
|
+
`$in operator requires an array value for field ${fieldName}`,
|
|
1685
|
+
fieldName,
|
|
1686
|
+
operator,
|
|
1687
|
+
fieldType
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
this.validateArrayValues(fieldName, value);
|
|
1691
|
+
if (value.length === 0) {
|
|
1692
|
+
return { sql: "1 = 0", params: [] };
|
|
1693
|
+
}
|
|
1694
|
+
const placeholders = value.map(() => "?").join(",");
|
|
1695
|
+
const convertedValues = value.map((v) => this.convertValueForSQLite(v));
|
|
1696
|
+
return {
|
|
1697
|
+
sql: `${fieldSql} IN (${placeholders})`,
|
|
1698
|
+
params: convertedValues
|
|
1699
|
+
};
|
|
1700
|
+
case "$nin":
|
|
1701
|
+
if (!Array.isArray(value)) {
|
|
1702
|
+
throw new InvalidOperatorError(
|
|
1703
|
+
`$nin operator requires an array value for field ${fieldName}`,
|
|
1704
|
+
fieldName,
|
|
1705
|
+
operator,
|
|
1706
|
+
fieldType
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
this.validateArrayValues(fieldName, value);
|
|
1710
|
+
if (value.length === 0) {
|
|
1711
|
+
return { sql: "", params: [] };
|
|
1712
|
+
}
|
|
1713
|
+
const ninPlaceholders = value.map(() => "?").join(",");
|
|
1714
|
+
const convertedNinValues = value.map(
|
|
1715
|
+
(v) => this.convertValueForSQLite(v)
|
|
1716
|
+
);
|
|
1717
|
+
return {
|
|
1718
|
+
sql: `${fieldSql} NOT IN (${ninPlaceholders})`,
|
|
1719
|
+
params: convertedNinValues
|
|
1720
|
+
};
|
|
1721
|
+
case "$exists":
|
|
1722
|
+
if (typeof value !== "boolean") {
|
|
1723
|
+
throw new InvalidOperatorError(
|
|
1724
|
+
`$exists operator requires a boolean value for field ${fieldName}`,
|
|
1725
|
+
fieldName,
|
|
1726
|
+
operator,
|
|
1727
|
+
fieldType
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
if (SYSTEM_FIELDS.has(fieldName)) {
|
|
1731
|
+
return value ? { sql: `${fieldSql} IS NOT NULL`, params: [] } : { sql: `${fieldSql} IS NULL`, params: [] };
|
|
1732
|
+
} else {
|
|
1733
|
+
return value ? {
|
|
1734
|
+
sql: `json_type(_data, '$.${fieldName}') IS NOT NULL`,
|
|
1735
|
+
params: []
|
|
1736
|
+
} : {
|
|
1737
|
+
sql: `json_type(_data, '$.${fieldName}') IS NULL`,
|
|
1738
|
+
params: []
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
case "$contains":
|
|
1742
|
+
if (fieldType && fieldType !== "stringset") {
|
|
1743
|
+
throw new InvalidOperatorError(
|
|
1744
|
+
`$contains operator is only supported for stringset fields, field ${fieldName} is type ${fieldType}`,
|
|
1745
|
+
fieldName,
|
|
1746
|
+
operator,
|
|
1747
|
+
fieldType
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
if (typeof value !== "string") {
|
|
1751
|
+
throw new InvalidOperatorError(
|
|
1752
|
+
`$contains operator requires a string value for field ${fieldName}`,
|
|
1753
|
+
fieldName,
|
|
1754
|
+
operator,
|
|
1755
|
+
fieldType
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
return {
|
|
1759
|
+
sql: `EXISTS (SELECT 1 FROM stringset_index WHERE stringset_index._type = ? AND stringset_index.field = ? AND stringset_index._record_id = records._id AND stringset_index.value = ?)`,
|
|
1760
|
+
params: [this.modelName, fieldName, value]
|
|
1761
|
+
};
|
|
1762
|
+
default:
|
|
1763
|
+
throw new InvalidOperatorError(
|
|
1764
|
+
`Unsupported operator: ${operator} for field ${fieldName}`,
|
|
1765
|
+
fieldName,
|
|
1766
|
+
operator,
|
|
1767
|
+
fieldType
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Build SELECT clause based on projection
|
|
1773
|
+
* For JSON schema, we need to extract fields from data_json
|
|
1774
|
+
*/
|
|
1775
|
+
buildSelectClause(projection) {
|
|
1776
|
+
if (!projection || Object.keys(projection).length === 0) {
|
|
1777
|
+
return "_id, _type, _data";
|
|
1778
|
+
}
|
|
1779
|
+
const includeFields = [];
|
|
1780
|
+
const excludeFields = [];
|
|
1781
|
+
let hasIncludes = false;
|
|
1782
|
+
let hasExcludes = false;
|
|
1783
|
+
for (const [field, include] of Object.entries(projection)) {
|
|
1784
|
+
if (include === 1) {
|
|
1785
|
+
includeFields.push(field);
|
|
1786
|
+
hasIncludes = true;
|
|
1787
|
+
} else if (include === 0) {
|
|
1788
|
+
excludeFields.push(field);
|
|
1789
|
+
hasExcludes = true;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
if (hasIncludes && hasExcludes) {
|
|
1793
|
+
throw new InvalidOperatorError(
|
|
1794
|
+
"Cannot mix inclusion and exclusion in projection",
|
|
1795
|
+
"projection",
|
|
1796
|
+
"mixed"
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
if (hasIncludes) {
|
|
1800
|
+
if (!includeFields.includes("id")) {
|
|
1801
|
+
includeFields.unshift("id");
|
|
1802
|
+
}
|
|
1803
|
+
const selectParts = ["_id", "_type"];
|
|
1804
|
+
for (const field of includeFields) {
|
|
1805
|
+
if (field === "id" || field === "type") continue;
|
|
1806
|
+
selectParts.push(
|
|
1807
|
+
`json_extract(_data, '$.${field}') AS ${quoteIdentifier(field)}`
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
return selectParts.join(", ");
|
|
1811
|
+
} else if (hasExcludes) {
|
|
1812
|
+
return "_id, _type, _data";
|
|
1813
|
+
}
|
|
1814
|
+
return "_id, _type, _data";
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Build LIMIT clause
|
|
1818
|
+
*/
|
|
1819
|
+
buildLimitClause(options) {
|
|
1820
|
+
if (options?.limit && options.limit > 0) {
|
|
1821
|
+
return { sql: options.limit.toString() };
|
|
1822
|
+
}
|
|
1823
|
+
return { sql: "" };
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Build pagination WHERE clause from cursor
|
|
1827
|
+
*/
|
|
1828
|
+
buildPaginationClause(options, sortFields, sortDirections) {
|
|
1829
|
+
if (!options?.uniqueStartKey || !sortFields || !sortDirections) {
|
|
1830
|
+
return { sql: "", params: [] };
|
|
1831
|
+
}
|
|
1832
|
+
try {
|
|
1833
|
+
const cursor = CursorManager.decodeCursor(options.uniqueStartKey);
|
|
1834
|
+
const direction = options.direction || 1;
|
|
1835
|
+
return CursorManager.buildPaginationConditions(
|
|
1836
|
+
cursor,
|
|
1837
|
+
sortFields,
|
|
1838
|
+
sortDirections,
|
|
1839
|
+
direction,
|
|
1840
|
+
(field) => this.getFieldSql(field)
|
|
1841
|
+
);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
console.warn("Invalid cursor provided, ignoring pagination:", error);
|
|
1844
|
+
return { sql: "", params: [] };
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Validate projection fields — skipped in schemaless mode
|
|
1849
|
+
*/
|
|
1850
|
+
validateProjection(projection) {
|
|
1851
|
+
if (!this.schema) return;
|
|
1852
|
+
for (const fieldName of Object.keys(projection)) {
|
|
1853
|
+
if (!this.schema.has(fieldName) && !SYSTEM_FIELDS.has(fieldName)) {
|
|
1854
|
+
throw new InvalidFieldError(
|
|
1855
|
+
`Unknown field: ${fieldName} in model ${this.modelName}`,
|
|
1856
|
+
fieldName,
|
|
1857
|
+
this.modelName
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Validate operator is supported for field type
|
|
1864
|
+
*/
|
|
1865
|
+
validateOperatorForType(operator, fieldType, allowedTypes) {
|
|
1866
|
+
if (!fieldType) {
|
|
1867
|
+
console.warn(`Field type not specified, allowing operator ${operator}`);
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (!allowedTypes.includes(fieldType)) {
|
|
1871
|
+
throw new InvalidOperatorError(
|
|
1872
|
+
`Operator ${operator} is not supported for field type ${fieldType}. Allowed types: ${allowedTypes.join(
|
|
1873
|
+
", "
|
|
1874
|
+
)}`,
|
|
1875
|
+
"unknown",
|
|
1876
|
+
operator,
|
|
1877
|
+
fieldType
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Validate field value matches expected type
|
|
1883
|
+
*/
|
|
1884
|
+
validateFieldValue(fieldName, value) {
|
|
1885
|
+
if (!this.schema) return;
|
|
1886
|
+
const fieldOptions = this.schema.get(fieldName);
|
|
1887
|
+
if (!fieldOptions || !fieldOptions.type) {
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
const expectedType = fieldOptions.type;
|
|
1891
|
+
const actualType = this.getValueType(value);
|
|
1892
|
+
if (!this.isTypeCompatible(expectedType, actualType, value)) {
|
|
1893
|
+
throw new InvalidOperatorError(
|
|
1894
|
+
`Field ${fieldName} expects ${expectedType}, got ${actualType}`,
|
|
1895
|
+
fieldName,
|
|
1896
|
+
"type_mismatch",
|
|
1897
|
+
expectedType
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Validate array values for $in/$nin operators
|
|
1903
|
+
*/
|
|
1904
|
+
validateArrayValues(fieldName, values) {
|
|
1905
|
+
for (const value of values) {
|
|
1906
|
+
this.validateFieldValue(fieldName, value);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Check if value is a primitive (not an object with operators)
|
|
1911
|
+
*/
|
|
1912
|
+
isPrimitiveValue(value) {
|
|
1913
|
+
if (value === null || value === void 0) {
|
|
1914
|
+
return true;
|
|
1915
|
+
}
|
|
1916
|
+
const type = typeof value;
|
|
1917
|
+
return type === "string" || type === "number" || type === "boolean" || value instanceof Date;
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Get the type of a value for validation
|
|
1921
|
+
*/
|
|
1922
|
+
getValueType(value) {
|
|
1923
|
+
if (value === null || value === void 0) {
|
|
1924
|
+
return "null";
|
|
1925
|
+
}
|
|
1926
|
+
if (value instanceof Date) {
|
|
1927
|
+
return "date";
|
|
1928
|
+
}
|
|
1929
|
+
return typeof value;
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Check if value type is compatible with field type
|
|
1933
|
+
*/
|
|
1934
|
+
isTypeCompatible(expectedType, actualType, value) {
|
|
1935
|
+
if (actualType === "null") {
|
|
1936
|
+
return true;
|
|
1937
|
+
}
|
|
1938
|
+
switch (expectedType) {
|
|
1939
|
+
case "id":
|
|
1940
|
+
return actualType === "string";
|
|
1941
|
+
// id fields are strings
|
|
1942
|
+
case "string":
|
|
1943
|
+
return actualType === "string";
|
|
1944
|
+
case "number":
|
|
1945
|
+
return actualType === "number" && !isNaN(value);
|
|
1946
|
+
case "boolean":
|
|
1947
|
+
return actualType === "boolean";
|
|
1948
|
+
case "date":
|
|
1949
|
+
return actualType === "date" || actualType === "string" && !isNaN(Date.parse(value));
|
|
1950
|
+
default:
|
|
1951
|
+
return true;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Convert value for SQLite compatibility
|
|
1956
|
+
*/
|
|
1957
|
+
convertValueForSQLite(value) {
|
|
1958
|
+
if (typeof value === "boolean") {
|
|
1959
|
+
return value ? 1 : 0;
|
|
1960
|
+
}
|
|
1961
|
+
return value;
|
|
1962
|
+
}
|
|
1963
|
+
normalizeDocumentIds(documents) {
|
|
1964
|
+
if (documents == null) {
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
const docArray = Array.isArray(documents) ? documents : [documents];
|
|
1968
|
+
const normalized = docArray.map((doc) => `${doc}`.trim()).filter((doc) => doc.length > 0);
|
|
1969
|
+
if (normalized.length === 0) {
|
|
1970
|
+
return [];
|
|
1971
|
+
}
|
|
1972
|
+
return Array.from(new Set(normalized));
|
|
1973
|
+
}
|
|
1974
|
+
buildDocumentClause(documents) {
|
|
1975
|
+
const normalized = this.normalizeDocumentIds(documents);
|
|
1976
|
+
if (normalized === null) {
|
|
1977
|
+
return null;
|
|
1978
|
+
}
|
|
1979
|
+
if (normalized.length === 0) {
|
|
1980
|
+
return { sql: "1 = 0", params: [] };
|
|
1981
|
+
}
|
|
1982
|
+
const placeholders = normalized.map(() => "?").join(", ");
|
|
1983
|
+
return {
|
|
1984
|
+
sql: `${quoteIdentifier("_meta_doc_id")} IN (${placeholders})`,
|
|
1985
|
+
params: normalized
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
// src/engines/cloudflare/createDocumentDO.ts
|
|
1991
|
+
function createDatabaseDO(config = {}) {
|
|
1992
|
+
const hooks = config.hooks;
|
|
1993
|
+
return class DocumentDO {
|
|
1994
|
+
/** @internal */
|
|
1995
|
+
_doState;
|
|
1996
|
+
/** @internal */
|
|
1997
|
+
_engine;
|
|
1998
|
+
/** @internal */
|
|
1999
|
+
_initialized = false;
|
|
2000
|
+
/** @internal — cache for $contains misuse check: "model:field" keys known to be in data_json */
|
|
2001
|
+
_containsMisuseCache = /* @__PURE__ */ new Set();
|
|
2002
|
+
constructor(state, _env) {
|
|
2003
|
+
this._doState = state;
|
|
2004
|
+
this._engine = new DurableObjectEngine({
|
|
2005
|
+
sql: state.storage.sql,
|
|
2006
|
+
storage: state.storage
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
get state() {
|
|
2010
|
+
return this._doState;
|
|
2011
|
+
}
|
|
2012
|
+
get engine() {
|
|
2013
|
+
return this._engine;
|
|
2014
|
+
}
|
|
2015
|
+
/** @internal */
|
|
2016
|
+
async _ensureInitialized() {
|
|
2017
|
+
if (!this._initialized) {
|
|
2018
|
+
await this._engine.ensureReady();
|
|
2019
|
+
this._initialized = true;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
async fetch(request) {
|
|
2023
|
+
try {
|
|
2024
|
+
await this._ensureInitialized();
|
|
2025
|
+
const url = new URL(request.url);
|
|
2026
|
+
const path = url.pathname;
|
|
2027
|
+
const docId = url.searchParams.get("docId") || "";
|
|
2028
|
+
if (request.method === "DELETE" && path === "/destroy") {
|
|
2029
|
+
await this._doState.storage.deleteAll();
|
|
2030
|
+
this._initialized = false;
|
|
2031
|
+
this._engine.initialized = false;
|
|
2032
|
+
return Response.json({
|
|
2033
|
+
deleted: true,
|
|
2034
|
+
deletedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
if (request.method !== "POST" && request.method !== "GET") {
|
|
2038
|
+
return this._errorResponse("Method not allowed", 405);
|
|
2039
|
+
}
|
|
2040
|
+
switch (path) {
|
|
2041
|
+
case "/query":
|
|
2042
|
+
return this._handleQuery(request, docId);
|
|
2043
|
+
case "/save":
|
|
2044
|
+
return this._handleSave(request, docId);
|
|
2045
|
+
case "/patch":
|
|
2046
|
+
return this._handlePatch(request, docId);
|
|
2047
|
+
case "/delete":
|
|
2048
|
+
return this._handleDelete(request, docId);
|
|
2049
|
+
case "/batch":
|
|
2050
|
+
return this._handleBatch(request, docId);
|
|
2051
|
+
case "/count":
|
|
2052
|
+
return this._handleCount(request, docId);
|
|
2053
|
+
case "/aggregate":
|
|
2054
|
+
return this._handleAggregate(request, docId);
|
|
2055
|
+
case "/increment":
|
|
2056
|
+
return this._handleIncrement(request, docId);
|
|
2057
|
+
case "/stringset/add":
|
|
2058
|
+
return this._handleStringSetAdd(request, docId);
|
|
2059
|
+
case "/stringset/remove":
|
|
2060
|
+
return this._handleStringSetRemove(request, docId);
|
|
2061
|
+
case "/indexes/sync":
|
|
2062
|
+
return this._handleIndexesSync(request);
|
|
2063
|
+
case "/index/register":
|
|
2064
|
+
return this._handleIndexRegister(request);
|
|
2065
|
+
case "/index/drop":
|
|
2066
|
+
return this._handleIndexDrop(request);
|
|
2067
|
+
case "/indexes":
|
|
2068
|
+
return this._handleIndexList(request);
|
|
2069
|
+
case "/unique-constraint/register":
|
|
2070
|
+
return this._handleUniqueConstraintRegister(request);
|
|
2071
|
+
case "/unique-constraint/drop":
|
|
2072
|
+
return this._handleUniqueConstraintDrop(request);
|
|
2073
|
+
case "/unique-constraints":
|
|
2074
|
+
return this._handleUniqueConstraintList(request);
|
|
2075
|
+
case "/health":
|
|
2076
|
+
return this._handleHealth();
|
|
2077
|
+
case "/describe":
|
|
2078
|
+
return this._handleDescribe(request);
|
|
2079
|
+
case "/models":
|
|
2080
|
+
return this._handleModelList();
|
|
2081
|
+
default:
|
|
2082
|
+
return this._errorResponse("Not found", 404);
|
|
2083
|
+
}
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
console.error("DO fetch error:", error);
|
|
2086
|
+
return this._errorResponse(
|
|
2087
|
+
error instanceof Error ? error.message : "Internal error",
|
|
2088
|
+
500
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
/** @internal */
|
|
2093
|
+
async _handleQuery(request, docId) {
|
|
2094
|
+
const body = await request.json();
|
|
2095
|
+
const { modelName, options } = body;
|
|
2096
|
+
let filter = body.filter || {};
|
|
2097
|
+
if (hooks?.beforeQuery) {
|
|
2098
|
+
const ctx = {
|
|
2099
|
+
modelName,
|
|
2100
|
+
docId,
|
|
2101
|
+
request,
|
|
2102
|
+
engine: this._engine,
|
|
2103
|
+
filter,
|
|
2104
|
+
options
|
|
2105
|
+
};
|
|
2106
|
+
const result = await hooks.beforeQuery(ctx);
|
|
2107
|
+
if (!result.allow) {
|
|
2108
|
+
return this._errorResponse(result.reason || "Query denied", 403);
|
|
2109
|
+
}
|
|
2110
|
+
if (result.injectFilter) {
|
|
2111
|
+
filter = { $and: [filter, result.injectFilter] };
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
const misuseError = this._checkContainsMisuse(modelName, filter);
|
|
2115
|
+
if (misuseError) return misuseError;
|
|
2116
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
2117
|
+
includeDocId: false
|
|
2118
|
+
});
|
|
2119
|
+
const { sql, params, sortFields } = translator.translateFind(filter, options);
|
|
2120
|
+
const rawResults = this._engine.execSqlSync(sql, params);
|
|
2121
|
+
let data = rawResults.map((row) => this._parseRow(row));
|
|
2122
|
+
if (hooks?.afterQuery) {
|
|
2123
|
+
const ctx = {
|
|
2124
|
+
modelName,
|
|
2125
|
+
docId,
|
|
2126
|
+
request,
|
|
2127
|
+
engine: this._engine,
|
|
2128
|
+
results: data
|
|
2129
|
+
};
|
|
2130
|
+
const result = await hooks.afterQuery(ctx);
|
|
2131
|
+
data = result.results;
|
|
2132
|
+
}
|
|
2133
|
+
if (options?.include && options.include.length > 0) {
|
|
2134
|
+
data = this._resolveIncludes(data, options.include, 0, modelName);
|
|
2135
|
+
}
|
|
2136
|
+
const limit = options?.limit;
|
|
2137
|
+
const hasMore = CursorManager.hasMoreResults(limit, data.length);
|
|
2138
|
+
const isFirstPage = !options?.uniqueStartKey;
|
|
2139
|
+
const cursors = CursorManager.generateResultCursors(
|
|
2140
|
+
data,
|
|
2141
|
+
sortFields || ["id"],
|
|
2142
|
+
options?.direction || 1,
|
|
2143
|
+
hasMore,
|
|
2144
|
+
isFirstPage
|
|
2145
|
+
);
|
|
2146
|
+
const response = {
|
|
2147
|
+
data,
|
|
2148
|
+
hasMore,
|
|
2149
|
+
...cursors
|
|
2150
|
+
};
|
|
2151
|
+
return Response.json(response);
|
|
2152
|
+
}
|
|
2153
|
+
/** @internal — Check for reserved field names in record data */
|
|
2154
|
+
_checkReservedFields(data) {
|
|
2155
|
+
for (const key of Object.keys(data)) {
|
|
2156
|
+
if (key === "id") continue;
|
|
2157
|
+
if (key.startsWith("_")) {
|
|
2158
|
+
return `Field '${key}' is reserved (fields starting with '_' are internal)`;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return null;
|
|
2162
|
+
}
|
|
2163
|
+
/** @internal */
|
|
2164
|
+
async _handleSave(request, docId) {
|
|
2165
|
+
const body = await request.json();
|
|
2166
|
+
const { modelName, id, data, stringSets, ifNotExists, condition } = body;
|
|
2167
|
+
if (!modelName || !id) {
|
|
2168
|
+
return this._errorResponse("modelName and id are required", 400);
|
|
2169
|
+
}
|
|
2170
|
+
if (!data) {
|
|
2171
|
+
return this._errorResponse("data is required", 400);
|
|
2172
|
+
}
|
|
2173
|
+
if (data) {
|
|
2174
|
+
const reservedError = this._checkReservedFields(data);
|
|
2175
|
+
if (reservedError) {
|
|
2176
|
+
return this._errorResponse(reservedError, 400);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2180
|
+
return this._errorResponse(
|
|
2181
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2182
|
+
409
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
if (ifNotExists && this._engine.recordExists(modelName, id)) {
|
|
2186
|
+
return this._errorResponse(
|
|
2187
|
+
`Record ${modelName}/${id} already exists`,
|
|
2188
|
+
409
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
if (hooks?.beforeSave) {
|
|
2192
|
+
const isNew = !this._engine.recordExists(modelName, id);
|
|
2193
|
+
const ctx = {
|
|
2194
|
+
modelName,
|
|
2195
|
+
docId,
|
|
2196
|
+
request,
|
|
2197
|
+
engine: this._engine,
|
|
2198
|
+
id,
|
|
2199
|
+
data,
|
|
2200
|
+
stringSets,
|
|
2201
|
+
isNew
|
|
2202
|
+
};
|
|
2203
|
+
const result = await hooks.beforeSave(ctx);
|
|
2204
|
+
if (!result.allow) {
|
|
2205
|
+
return this._errorResponse(result.reason || "Save denied", 403);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2209
|
+
modelName,
|
|
2210
|
+
id,
|
|
2211
|
+
data
|
|
2212
|
+
);
|
|
2213
|
+
if (violation) {
|
|
2214
|
+
return this._errorResponse(violation, 409);
|
|
2215
|
+
}
|
|
2216
|
+
if (stringSets && Object.keys(stringSets).length > 0) {
|
|
2217
|
+
await this._engine.insertWithStringSets(
|
|
2218
|
+
modelName,
|
|
2219
|
+
{ id, ...data },
|
|
2220
|
+
stringSets
|
|
2221
|
+
);
|
|
2222
|
+
} else {
|
|
2223
|
+
await this._engine.insert(modelName, { id, ...data });
|
|
2224
|
+
}
|
|
2225
|
+
this._engine.trackModelFields(modelName, data);
|
|
2226
|
+
const response = { success: true, id };
|
|
2227
|
+
return Response.json(response);
|
|
2228
|
+
}
|
|
2229
|
+
/** @internal — Partial update: merge provided fields into existing record */
|
|
2230
|
+
async _handlePatch(request, docId) {
|
|
2231
|
+
const body = await request.json();
|
|
2232
|
+
const { modelName, id, data, stringSets, condition } = body;
|
|
2233
|
+
if (!modelName || !id || !data) {
|
|
2234
|
+
return this._errorResponse("modelName, id, and data are required", 400);
|
|
2235
|
+
}
|
|
2236
|
+
const reservedError = this._checkReservedFields(data);
|
|
2237
|
+
if (reservedError) {
|
|
2238
|
+
return this._errorResponse(reservedError, 400);
|
|
2239
|
+
}
|
|
2240
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2241
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2242
|
+
}
|
|
2243
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2244
|
+
return this._errorResponse(
|
|
2245
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2246
|
+
409
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
if (hooks?.beforeSave) {
|
|
2250
|
+
const ctx = {
|
|
2251
|
+
modelName,
|
|
2252
|
+
docId,
|
|
2253
|
+
request,
|
|
2254
|
+
engine: this._engine,
|
|
2255
|
+
id,
|
|
2256
|
+
data,
|
|
2257
|
+
stringSets,
|
|
2258
|
+
isNew: false
|
|
2259
|
+
};
|
|
2260
|
+
const result = await hooks.beforeSave(ctx);
|
|
2261
|
+
if (!result.allow) {
|
|
2262
|
+
return this._errorResponse(result.reason || "Save denied", 403);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
const existing = this._engine.execSqlSync(
|
|
2266
|
+
"SELECT _data FROM records WHERE _id = ? AND _type = ?",
|
|
2267
|
+
[id, modelName]
|
|
2268
|
+
);
|
|
2269
|
+
if (existing.length > 0) {
|
|
2270
|
+
const existingData = JSON.parse(existing[0]._data);
|
|
2271
|
+
const mergedData = { ...existingData, ...data };
|
|
2272
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2273
|
+
modelName,
|
|
2274
|
+
id,
|
|
2275
|
+
mergedData
|
|
2276
|
+
);
|
|
2277
|
+
if (violation) {
|
|
2278
|
+
return this._errorResponse(violation, 409);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
const found = this._engine.patchRecord(modelName, id, data, stringSets);
|
|
2282
|
+
if (!found) {
|
|
2283
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2284
|
+
}
|
|
2285
|
+
this._engine.trackModelFields(modelName, data);
|
|
2286
|
+
const response = { success: true, id };
|
|
2287
|
+
return Response.json(response);
|
|
2288
|
+
}
|
|
2289
|
+
/** @internal */
|
|
2290
|
+
async _handleDelete(request, docId) {
|
|
2291
|
+
const body = await request.json();
|
|
2292
|
+
const { modelName, id, condition } = body;
|
|
2293
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2294
|
+
return this._errorResponse(
|
|
2295
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2296
|
+
409
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
if (hooks?.beforeDelete) {
|
|
2300
|
+
let record = null;
|
|
2301
|
+
const rows = this._engine.execSqlSync(
|
|
2302
|
+
"SELECT _id, _type, _data FROM records WHERE _id = ? AND _type = ?",
|
|
2303
|
+
[id, modelName]
|
|
2304
|
+
);
|
|
2305
|
+
if (rows.length > 0) {
|
|
2306
|
+
record = this._parseRow(rows[0]);
|
|
2307
|
+
}
|
|
2308
|
+
const ctx = {
|
|
2309
|
+
modelName,
|
|
2310
|
+
docId,
|
|
2311
|
+
request,
|
|
2312
|
+
engine: this._engine,
|
|
2313
|
+
id,
|
|
2314
|
+
record
|
|
2315
|
+
};
|
|
2316
|
+
const result = await hooks.beforeDelete(ctx);
|
|
2317
|
+
if (!result.allow) {
|
|
2318
|
+
return this._errorResponse(result.reason || "Delete denied", 403);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
await this._engine.deleteWithStringSets(modelName, id);
|
|
2322
|
+
const response = { success: true };
|
|
2323
|
+
return Response.json(response);
|
|
2324
|
+
}
|
|
2325
|
+
/** @internal — Execute multiple save/patch/delete operations in a single transaction */
|
|
2326
|
+
async _handleBatch(request, docId) {
|
|
2327
|
+
const body = await request.json();
|
|
2328
|
+
const { operations } = body;
|
|
2329
|
+
if (!operations || !Array.isArray(operations) || operations.length === 0) {
|
|
2330
|
+
return this._errorResponse("operations array is required and must not be empty", 400);
|
|
2331
|
+
}
|
|
2332
|
+
const hookDenials = /* @__PURE__ */ new Map();
|
|
2333
|
+
for (let i = 0; i < operations.length; i++) {
|
|
2334
|
+
const op = operations[i];
|
|
2335
|
+
if (!op.modelName || !op.id || !op.op) {
|
|
2336
|
+
return this._errorResponse(
|
|
2337
|
+
`Operation ${i}: modelName, id, and op are required`,
|
|
2338
|
+
400
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
if (op.op === "save" || op.op === "patch") {
|
|
2342
|
+
if (!op.data) {
|
|
2343
|
+
return this._errorResponse(
|
|
2344
|
+
`Operation ${i}: data is required for ${op.op}`,
|
|
2345
|
+
400
|
|
2346
|
+
);
|
|
2347
|
+
}
|
|
2348
|
+
const reservedError = this._checkReservedFields(op.data);
|
|
2349
|
+
if (reservedError) {
|
|
2350
|
+
return this._errorResponse(
|
|
2351
|
+
`Operation ${i}: ${reservedError}`,
|
|
2352
|
+
400
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
if (hooks?.beforeSave) {
|
|
2356
|
+
const isNew = op.op === "save" ? !this._engine.recordExists(op.modelName, op.id) : false;
|
|
2357
|
+
const ctx = {
|
|
2358
|
+
modelName: op.modelName,
|
|
2359
|
+
docId,
|
|
2360
|
+
request,
|
|
2361
|
+
engine: this._engine,
|
|
2362
|
+
id: op.id,
|
|
2363
|
+
data: op.data,
|
|
2364
|
+
stringSets: op.stringSets,
|
|
2365
|
+
isNew
|
|
2366
|
+
};
|
|
2367
|
+
const result = await hooks.beforeSave(ctx);
|
|
2368
|
+
if (!result.allow) {
|
|
2369
|
+
hookDenials.set(i, result.reason || "Save denied");
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
} else if (op.op === "delete") {
|
|
2373
|
+
if (hooks?.beforeDelete) {
|
|
2374
|
+
let record = null;
|
|
2375
|
+
const rows = this._engine.execSqlSync(
|
|
2376
|
+
"SELECT _id, _type, _data FROM records WHERE _id = ? AND _type = ?",
|
|
2377
|
+
[op.id, op.modelName]
|
|
2378
|
+
);
|
|
2379
|
+
if (rows.length > 0) {
|
|
2380
|
+
record = this._parseRow(rows[0]);
|
|
2381
|
+
}
|
|
2382
|
+
const ctx = {
|
|
2383
|
+
modelName: op.modelName,
|
|
2384
|
+
docId,
|
|
2385
|
+
request,
|
|
2386
|
+
engine: this._engine,
|
|
2387
|
+
id: op.id,
|
|
2388
|
+
record
|
|
2389
|
+
};
|
|
2390
|
+
const result = await hooks.beforeDelete(ctx);
|
|
2391
|
+
if (!result.allow) {
|
|
2392
|
+
hookDenials.set(i, result.reason || "Delete denied");
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
} else if (op.op === "increment") {
|
|
2396
|
+
if (!op.fields || typeof op.fields !== "object") {
|
|
2397
|
+
return this._errorResponse(
|
|
2398
|
+
`Operation ${i}: fields is required for increment`,
|
|
2399
|
+
400
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
} else if (op.op === "addToSet" || op.op === "removeFromSet") {
|
|
2403
|
+
if (!op.stringSets || typeof op.stringSets !== "object") {
|
|
2404
|
+
return this._errorResponse(
|
|
2405
|
+
`Operation ${i}: stringSets is required for ${op.op}`,
|
|
2406
|
+
400
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
} else {
|
|
2410
|
+
return this._errorResponse(
|
|
2411
|
+
`Operation ${i}: unknown op '${op.op}'.`,
|
|
2412
|
+
400
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
const results = [];
|
|
2417
|
+
const fieldsToTrack = [];
|
|
2418
|
+
this._engine.transactionSync(() => {
|
|
2419
|
+
for (let i = 0; i < operations.length; i++) {
|
|
2420
|
+
const op = operations[i];
|
|
2421
|
+
if (hookDenials.has(i)) {
|
|
2422
|
+
results.push({
|
|
2423
|
+
success: false,
|
|
2424
|
+
id: op.id,
|
|
2425
|
+
error: hookDenials.get(i)
|
|
2426
|
+
});
|
|
2427
|
+
continue;
|
|
2428
|
+
}
|
|
2429
|
+
if (op.condition && !this._checkCondition(op.modelName, op.id, op.condition)) {
|
|
2430
|
+
results.push({
|
|
2431
|
+
success: false,
|
|
2432
|
+
id: op.id,
|
|
2433
|
+
error: `Condition not met for ${op.modelName}/${op.id}`
|
|
2434
|
+
});
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
if (op.op === "save") {
|
|
2438
|
+
if (op.ifNotExists && this._engine.recordExists(op.modelName, op.id)) {
|
|
2439
|
+
results.push({
|
|
2440
|
+
success: false,
|
|
2441
|
+
id: op.id,
|
|
2442
|
+
error: `Record ${op.modelName}/${op.id} already exists`
|
|
2443
|
+
});
|
|
2444
|
+
continue;
|
|
2445
|
+
}
|
|
2446
|
+
if (op.data) {
|
|
2447
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2448
|
+
op.modelName,
|
|
2449
|
+
op.id,
|
|
2450
|
+
op.data
|
|
2451
|
+
);
|
|
2452
|
+
if (violation) {
|
|
2453
|
+
results.push({
|
|
2454
|
+
success: false,
|
|
2455
|
+
id: op.id,
|
|
2456
|
+
error: violation
|
|
2457
|
+
});
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
if (op.stringSets && Object.keys(op.stringSets).length > 0) {
|
|
2462
|
+
const { id: _id, ...fieldsForJson } = op.data;
|
|
2463
|
+
const jsonFields = { ...fieldsForJson, ...op.stringSets };
|
|
2464
|
+
const dataJson = JSON.stringify(jsonFields);
|
|
2465
|
+
this._engine.execSqlSync(
|
|
2466
|
+
"INSERT OR REPLACE INTO records (_id, _type, _data) VALUES (?, ?, ?)",
|
|
2467
|
+
[op.id, op.modelName, dataJson]
|
|
2468
|
+
);
|
|
2469
|
+
this._engine.execSqlSync(
|
|
2470
|
+
"DELETE FROM stringset_index WHERE _type = ? AND _record_id = ?",
|
|
2471
|
+
[op.modelName, op.id]
|
|
2472
|
+
);
|
|
2473
|
+
for (const [fieldName, values] of Object.entries(op.stringSets)) {
|
|
2474
|
+
for (const value of values) {
|
|
2475
|
+
this._engine.execSqlSync(
|
|
2476
|
+
"INSERT OR IGNORE INTO stringset_index (_record_id, _type, field, value) VALUES (?, ?, ?, ?)",
|
|
2477
|
+
[op.id, op.modelName, fieldName, value]
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
} else {
|
|
2482
|
+
const { id: _id, ...fieldsForJson } = op.data;
|
|
2483
|
+
const dataJson = JSON.stringify(fieldsForJson);
|
|
2484
|
+
this._engine.execSqlSync(
|
|
2485
|
+
"INSERT OR REPLACE INTO records (_id, _type, _data) VALUES (?, ?, ?)",
|
|
2486
|
+
[op.id, op.modelName, dataJson]
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
|
|
2490
|
+
results.push({ success: true, id: op.id });
|
|
2491
|
+
} else if (op.op === "patch") {
|
|
2492
|
+
if (!this._engine.recordExists(op.modelName, op.id)) {
|
|
2493
|
+
results.push({
|
|
2494
|
+
success: false,
|
|
2495
|
+
id: op.id,
|
|
2496
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2497
|
+
});
|
|
2498
|
+
continue;
|
|
2499
|
+
}
|
|
2500
|
+
if (op.data) {
|
|
2501
|
+
const existing = this._engine.execSqlSync(
|
|
2502
|
+
"SELECT _data FROM records WHERE _id = ? AND _type = ?",
|
|
2503
|
+
[op.id, op.modelName]
|
|
2504
|
+
);
|
|
2505
|
+
if (existing.length > 0) {
|
|
2506
|
+
const existingData = JSON.parse(existing[0]._data);
|
|
2507
|
+
const mergedData = { ...existingData, ...op.data };
|
|
2508
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2509
|
+
op.modelName,
|
|
2510
|
+
op.id,
|
|
2511
|
+
mergedData
|
|
2512
|
+
);
|
|
2513
|
+
if (violation) {
|
|
2514
|
+
results.push({
|
|
2515
|
+
success: false,
|
|
2516
|
+
id: op.id,
|
|
2517
|
+
error: violation
|
|
2518
|
+
});
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
const found = this._engine.patchRecordRaw(
|
|
2524
|
+
op.modelName,
|
|
2525
|
+
op.id,
|
|
2526
|
+
op.data,
|
|
2527
|
+
op.stringSets
|
|
2528
|
+
);
|
|
2529
|
+
if (!found) {
|
|
2530
|
+
results.push({
|
|
2531
|
+
success: false,
|
|
2532
|
+
id: op.id,
|
|
2533
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2534
|
+
});
|
|
2535
|
+
} else {
|
|
2536
|
+
if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
|
|
2537
|
+
results.push({ success: true, id: op.id });
|
|
2538
|
+
}
|
|
2539
|
+
} else if (op.op === "delete") {
|
|
2540
|
+
this._engine.execSqlSync(
|
|
2541
|
+
"DELETE FROM records WHERE _id = ? AND _type = ?",
|
|
2542
|
+
[op.id, op.modelName]
|
|
2543
|
+
);
|
|
2544
|
+
this._engine.execSqlSync(
|
|
2545
|
+
"DELETE FROM stringset_index WHERE _record_id = ? AND _type = ?",
|
|
2546
|
+
[op.id, op.modelName]
|
|
2547
|
+
);
|
|
2548
|
+
results.push({ success: true, id: op.id });
|
|
2549
|
+
} else if (op.op === "increment") {
|
|
2550
|
+
const newValues = this._engine.incrementFieldsRaw(
|
|
2551
|
+
op.modelName,
|
|
2552
|
+
op.id,
|
|
2553
|
+
op.fields
|
|
2554
|
+
);
|
|
2555
|
+
if (!newValues) {
|
|
2556
|
+
results.push({
|
|
2557
|
+
success: false,
|
|
2558
|
+
id: op.id,
|
|
2559
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2560
|
+
});
|
|
2561
|
+
} else {
|
|
2562
|
+
results.push({ success: true, id: op.id, values: newValues });
|
|
2563
|
+
}
|
|
2564
|
+
} else if (op.op === "addToSet") {
|
|
2565
|
+
if (!this._engine.recordExists(op.modelName, op.id)) {
|
|
2566
|
+
results.push({
|
|
2567
|
+
success: false,
|
|
2568
|
+
id: op.id,
|
|
2569
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2570
|
+
});
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
this._engine.addToStringSetsRaw(
|
|
2574
|
+
op.modelName,
|
|
2575
|
+
op.id,
|
|
2576
|
+
op.stringSets
|
|
2577
|
+
);
|
|
2578
|
+
results.push({ success: true, id: op.id });
|
|
2579
|
+
} else if (op.op === "removeFromSet") {
|
|
2580
|
+
if (!this._engine.recordExists(op.modelName, op.id)) {
|
|
2581
|
+
results.push({
|
|
2582
|
+
success: false,
|
|
2583
|
+
id: op.id,
|
|
2584
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2585
|
+
});
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2588
|
+
this._engine.removeFromStringSetsRaw(
|
|
2589
|
+
op.modelName,
|
|
2590
|
+
op.id,
|
|
2591
|
+
op.stringSets
|
|
2592
|
+
);
|
|
2593
|
+
results.push({ success: true, id: op.id });
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
for (const entry of fieldsToTrack) {
|
|
2598
|
+
this._engine.trackModelFields(entry.modelName, entry.data);
|
|
2599
|
+
}
|
|
2600
|
+
const response = { results };
|
|
2601
|
+
return Response.json(response);
|
|
2602
|
+
}
|
|
2603
|
+
/** @internal */
|
|
2604
|
+
async _handleCount(request, docId) {
|
|
2605
|
+
const body = await request.json();
|
|
2606
|
+
const { modelName } = body;
|
|
2607
|
+
let filter = body.filter || {};
|
|
2608
|
+
if (hooks?.beforeQuery) {
|
|
2609
|
+
const ctx = {
|
|
2610
|
+
modelName,
|
|
2611
|
+
docId,
|
|
2612
|
+
request,
|
|
2613
|
+
engine: this._engine,
|
|
2614
|
+
filter
|
|
2615
|
+
};
|
|
2616
|
+
const result = await hooks.beforeQuery(ctx);
|
|
2617
|
+
if (!result.allow) {
|
|
2618
|
+
return this._errorResponse(result.reason || "Query denied", 403);
|
|
2619
|
+
}
|
|
2620
|
+
if (result.injectFilter) {
|
|
2621
|
+
filter = { $and: [filter, result.injectFilter] };
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
const misuseError = this._checkContainsMisuse(modelName, filter);
|
|
2625
|
+
if (misuseError) return misuseError;
|
|
2626
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
2627
|
+
includeDocId: false
|
|
2628
|
+
});
|
|
2629
|
+
const { sql, params } = translator.translateCount(filter);
|
|
2630
|
+
const results = this._engine.execSqlSync(sql, params);
|
|
2631
|
+
const response = { count: results[0]?.count ?? 0 };
|
|
2632
|
+
return Response.json(response);
|
|
2633
|
+
}
|
|
2634
|
+
/** @internal — Atomically increment/decrement numeric fields */
|
|
2635
|
+
async _handleIncrement(request, _docId) {
|
|
2636
|
+
const body = await request.json();
|
|
2637
|
+
const { modelName, id, fields, condition } = body;
|
|
2638
|
+
if (!modelName || !id || !fields) {
|
|
2639
|
+
return this._errorResponse("modelName, id, and fields are required", 400);
|
|
2640
|
+
}
|
|
2641
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2642
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2643
|
+
}
|
|
2644
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2645
|
+
return this._errorResponse(
|
|
2646
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2647
|
+
409
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
const newValues = this._engine.incrementFields(modelName, id, fields);
|
|
2651
|
+
if (!newValues) {
|
|
2652
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2653
|
+
}
|
|
2654
|
+
const response = { success: true, id, values: newValues };
|
|
2655
|
+
return Response.json(response);
|
|
2656
|
+
}
|
|
2657
|
+
/** @internal — Atomically add values to StringSet fields */
|
|
2658
|
+
async _handleStringSetAdd(request, _docId) {
|
|
2659
|
+
const body = await request.json();
|
|
2660
|
+
const { modelName, id, sets, condition } = body;
|
|
2661
|
+
if (!modelName || !id || !sets) {
|
|
2662
|
+
return this._errorResponse("modelName, id, and sets are required", 400);
|
|
2663
|
+
}
|
|
2664
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2665
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2666
|
+
}
|
|
2667
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2668
|
+
return this._errorResponse(
|
|
2669
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2670
|
+
409
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
this._engine.addToStringSets(modelName, id, sets);
|
|
2674
|
+
const response = { success: true };
|
|
2675
|
+
return Response.json(response);
|
|
2676
|
+
}
|
|
2677
|
+
/** @internal — Atomically remove values from StringSet fields */
|
|
2678
|
+
async _handleStringSetRemove(request, _docId) {
|
|
2679
|
+
const body = await request.json();
|
|
2680
|
+
const { modelName, id, sets, condition } = body;
|
|
2681
|
+
if (!modelName || !id || !sets) {
|
|
2682
|
+
return this._errorResponse("modelName, id, and sets are required", 400);
|
|
2683
|
+
}
|
|
2684
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2685
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2686
|
+
}
|
|
2687
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2688
|
+
return this._errorResponse(
|
|
2689
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2690
|
+
409
|
|
2691
|
+
);
|
|
2692
|
+
}
|
|
2693
|
+
this._engine.removeFromStringSets(modelName, id, sets);
|
|
2694
|
+
const response = { success: true };
|
|
2695
|
+
return Response.json(response);
|
|
2696
|
+
}
|
|
2697
|
+
async _handleAggregate(request, docId) {
|
|
2698
|
+
const body = await request.json();
|
|
2699
|
+
const { modelName, options: aggOptions } = body;
|
|
2700
|
+
if (!modelName || !aggOptions || !aggOptions.groupBy || !aggOptions.operations) {
|
|
2701
|
+
return this._errorResponse(
|
|
2702
|
+
"modelName, options.groupBy, and options.operations are required",
|
|
2703
|
+
400
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
for (const op of aggOptions.operations) {
|
|
2707
|
+
if (op.type !== "count" && !op.field) {
|
|
2708
|
+
return this._errorResponse(
|
|
2709
|
+
`Operation '${op.type}' requires a field parameter`,
|
|
2710
|
+
400
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
if (op.type === "count" && op.field) {
|
|
2714
|
+
return this._errorResponse(
|
|
2715
|
+
`Operation 'count' should not have a field parameter`,
|
|
2716
|
+
400
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
let filter = aggOptions.filter || {};
|
|
2721
|
+
if (hooks?.beforeQuery) {
|
|
2722
|
+
const ctx = {
|
|
2723
|
+
modelName,
|
|
2724
|
+
docId,
|
|
2725
|
+
request,
|
|
2726
|
+
engine: this._engine,
|
|
2727
|
+
filter
|
|
2728
|
+
};
|
|
2729
|
+
const result = await hooks.beforeQuery(ctx);
|
|
2730
|
+
if (!result.allow) {
|
|
2731
|
+
return this._errorResponse(result.reason || "Query denied", 403);
|
|
2732
|
+
}
|
|
2733
|
+
if (result.injectFilter) {
|
|
2734
|
+
filter = { $and: [filter, result.injectFilter] };
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
const misuseError = this._checkContainsMisuse(modelName, filter);
|
|
2738
|
+
if (misuseError) return misuseError;
|
|
2739
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
2740
|
+
includeDocId: false
|
|
2741
|
+
});
|
|
2742
|
+
const regularGroupBy = [];
|
|
2743
|
+
const stringSetMemberships = [];
|
|
2744
|
+
let stringSetFacetField = null;
|
|
2745
|
+
for (const groupBy of aggOptions.groupBy) {
|
|
2746
|
+
if (typeof groupBy === "string") {
|
|
2747
|
+
regularGroupBy.push(groupBy);
|
|
2748
|
+
} else {
|
|
2749
|
+
assertValidIdentifier(groupBy.field, "aggregation groupBy field");
|
|
2750
|
+
stringSetMemberships.push(groupBy);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
const confirmedRegular = [];
|
|
2754
|
+
for (const field of regularGroupBy) {
|
|
2755
|
+
assertValidIdentifier(field, "aggregation groupBy field");
|
|
2756
|
+
const ssCheck = this._engine.execSqlSync(
|
|
2757
|
+
"SELECT 1 FROM stringset_index WHERE _type = ? AND field = ? LIMIT 1",
|
|
2758
|
+
[modelName, field]
|
|
2759
|
+
);
|
|
2760
|
+
if (ssCheck.length > 0) {
|
|
2761
|
+
if (stringSetFacetField !== null) {
|
|
2762
|
+
return this._errorResponse(
|
|
2763
|
+
"Multiple StringSet facet fields not supported in single aggregation",
|
|
2764
|
+
400
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
stringSetFacetField = field;
|
|
2768
|
+
} else {
|
|
2769
|
+
confirmedRegular.push(field);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
try {
|
|
2773
|
+
let sql;
|
|
2774
|
+
let params = [];
|
|
2775
|
+
const aliasMap = [];
|
|
2776
|
+
if (stringSetFacetField && confirmedRegular.length === 0 && stringSetMemberships.length === 0) {
|
|
2777
|
+
const result = this._buildStringSetFacetSql(
|
|
2778
|
+
modelName,
|
|
2779
|
+
stringSetFacetField,
|
|
2780
|
+
aggOptions,
|
|
2781
|
+
filter,
|
|
2782
|
+
translator
|
|
2783
|
+
);
|
|
2784
|
+
sql = result.sql;
|
|
2785
|
+
params = result.params;
|
|
2786
|
+
} else {
|
|
2787
|
+
const result = this._buildRegularAggregationSql(
|
|
2788
|
+
modelName,
|
|
2789
|
+
confirmedRegular,
|
|
2790
|
+
stringSetMemberships,
|
|
2791
|
+
aggOptions,
|
|
2792
|
+
filter,
|
|
2793
|
+
translator
|
|
2794
|
+
);
|
|
2795
|
+
sql = result.sql;
|
|
2796
|
+
params = result.params;
|
|
2797
|
+
aliasMap.push(...result.aliasMap);
|
|
2798
|
+
}
|
|
2799
|
+
const rawResults = this._engine.execSqlSync(sql, params);
|
|
2800
|
+
const processed = this._processAggregationResults(rawResults, aggOptions, aliasMap, stringSetFacetField);
|
|
2801
|
+
const response = { result: processed };
|
|
2802
|
+
return Response.json(response);
|
|
2803
|
+
} catch (err) {
|
|
2804
|
+
return this._errorResponse(
|
|
2805
|
+
err instanceof Error ? err.message : "Aggregation failed",
|
|
2806
|
+
500
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
/** @internal — Build SQL for StringSet facet aggregation */
|
|
2811
|
+
_buildStringSetFacetSql(modelName, facetField, aggOptions, filter, translator) {
|
|
2812
|
+
const selectParts = ["stringset_index.value AS group_key"];
|
|
2813
|
+
for (const op of aggOptions.operations) {
|
|
2814
|
+
switch (op.type) {
|
|
2815
|
+
case "count":
|
|
2816
|
+
selectParts.push("COUNT(*) AS count");
|
|
2817
|
+
break;
|
|
2818
|
+
case "sum":
|
|
2819
|
+
selectParts.push(`SUM(${translator.getFieldSql(op.field)}) AS sum_${op.field}`);
|
|
2820
|
+
break;
|
|
2821
|
+
case "avg":
|
|
2822
|
+
selectParts.push(`AVG(${translator.getFieldSql(op.field)}) AS avg_${op.field}`);
|
|
2823
|
+
break;
|
|
2824
|
+
case "min":
|
|
2825
|
+
selectParts.push(`MIN(${translator.getFieldSql(op.field)}) AS min_${op.field}`);
|
|
2826
|
+
break;
|
|
2827
|
+
case "max":
|
|
2828
|
+
selectParts.push(`MAX(${translator.getFieldSql(op.field)}) AS max_${op.field}`);
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
let sql = `SELECT ${selectParts.join(", ")} FROM stringset_index`;
|
|
2833
|
+
sql += ` INNER JOIN records ON stringset_index._record_id = records._id AND records._type = stringset_index._type`;
|
|
2834
|
+
const conditions = [
|
|
2835
|
+
"stringset_index._type = ?",
|
|
2836
|
+
"stringset_index.field = ?"
|
|
2837
|
+
];
|
|
2838
|
+
const params = [modelName, facetField];
|
|
2839
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
2840
|
+
const whereClause = translator.translateFilter(filter);
|
|
2841
|
+
if (whereClause.sql) {
|
|
2842
|
+
conditions.push(whereClause.sql);
|
|
2843
|
+
params.push(...whereClause.params);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
2847
|
+
sql += " GROUP BY stringset_index.value";
|
|
2848
|
+
if (aggOptions.sort) {
|
|
2849
|
+
const sortExpr = aggOptions.sort.field === "count" ? "COUNT(*)" : aggOptions.sort.field;
|
|
2850
|
+
const dir = aggOptions.sort.direction === 1 ? "ASC" : "DESC";
|
|
2851
|
+
sql += ` ORDER BY ${sortExpr} ${dir}`;
|
|
2852
|
+
}
|
|
2853
|
+
if (aggOptions.limit) {
|
|
2854
|
+
sql += ` LIMIT ${Number(aggOptions.limit)}`;
|
|
2855
|
+
}
|
|
2856
|
+
return { sql, params };
|
|
2857
|
+
}
|
|
2858
|
+
/** @internal — Build SQL for regular field aggregation */
|
|
2859
|
+
_buildRegularAggregationSql(modelName, regularGroupBy, stringSetMemberships, aggOptions, filter, translator) {
|
|
2860
|
+
const selectParts = [];
|
|
2861
|
+
const groupByParts = [];
|
|
2862
|
+
const aliasMap = [];
|
|
2863
|
+
for (const field of regularGroupBy) {
|
|
2864
|
+
const fieldSql = translator.getFieldSql(field);
|
|
2865
|
+
selectParts.push(`${fieldSql} AS "${field}"`);
|
|
2866
|
+
groupByParts.push(fieldSql);
|
|
2867
|
+
}
|
|
2868
|
+
for (let i = 0; i < stringSetMemberships.length; i++) {
|
|
2869
|
+
const m = stringSetMemberships[i];
|
|
2870
|
+
const alias = `ss_${m.field}_${i}`;
|
|
2871
|
+
selectParts.push(
|
|
2872
|
+
`CASE WHEN ${alias}._record_id IS NOT NULL THEN 'true' ELSE 'false' END AS "${alias}"`
|
|
2873
|
+
);
|
|
2874
|
+
groupByParts.push(`CASE WHEN ${alias}._record_id IS NOT NULL THEN 'true' ELSE 'false' END`);
|
|
2875
|
+
aliasMap.push({ alias, field: m.field, contains: m.contains });
|
|
2876
|
+
}
|
|
2877
|
+
for (const op of aggOptions.operations) {
|
|
2878
|
+
switch (op.type) {
|
|
2879
|
+
case "count":
|
|
2880
|
+
selectParts.push("COUNT(*) AS count");
|
|
2881
|
+
break;
|
|
2882
|
+
case "sum":
|
|
2883
|
+
selectParts.push(`SUM(${translator.getFieldSql(op.field)}) AS sum_${op.field}`);
|
|
2884
|
+
break;
|
|
2885
|
+
case "avg":
|
|
2886
|
+
selectParts.push(`AVG(${translator.getFieldSql(op.field)}) AS avg_${op.field}`);
|
|
2887
|
+
break;
|
|
2888
|
+
case "min":
|
|
2889
|
+
selectParts.push(`MIN(${translator.getFieldSql(op.field)}) AS min_${op.field}`);
|
|
2890
|
+
break;
|
|
2891
|
+
case "max":
|
|
2892
|
+
selectParts.push(`MAX(${translator.getFieldSql(op.field)}) AS max_${op.field}`);
|
|
2893
|
+
break;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
let sql = `SELECT ${selectParts.join(", ")} FROM records`;
|
|
2897
|
+
const joinParams = [];
|
|
2898
|
+
for (const entry of aliasMap) {
|
|
2899
|
+
sql += ` LEFT JOIN stringset_index AS ${entry.alias}`;
|
|
2900
|
+
sql += ` ON ${entry.alias}._record_id = records._id`;
|
|
2901
|
+
sql += ` AND ${entry.alias}._type = records._type`;
|
|
2902
|
+
sql += ` AND ${entry.alias}.field = ?`;
|
|
2903
|
+
sql += ` AND ${entry.alias}.value = ?`;
|
|
2904
|
+
joinParams.push(entry.field, entry.contains);
|
|
2905
|
+
}
|
|
2906
|
+
const conditions = ["records._type = ?"];
|
|
2907
|
+
const params = [...joinParams, modelName];
|
|
2908
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
2909
|
+
const whereClause = translator.translateFilter(filter);
|
|
2910
|
+
if (whereClause.sql) {
|
|
2911
|
+
conditions.push(whereClause.sql);
|
|
2912
|
+
params.push(...whereClause.params);
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
2916
|
+
if (groupByParts.length > 0) {
|
|
2917
|
+
sql += ` GROUP BY ${groupByParts.join(", ")}`;
|
|
2918
|
+
}
|
|
2919
|
+
if (aggOptions.sort) {
|
|
2920
|
+
const sortField = aggOptions.sort.field;
|
|
2921
|
+
let sortExpr;
|
|
2922
|
+
if (sortField === "count") {
|
|
2923
|
+
sortExpr = "COUNT(*)";
|
|
2924
|
+
} else if (sortField.startsWith("sum_") || sortField.startsWith("avg_") || sortField.startsWith("min_") || sortField.startsWith("max_")) {
|
|
2925
|
+
sortExpr = `"${sortField}"`;
|
|
2926
|
+
} else {
|
|
2927
|
+
const aliasEntry = aliasMap.find((a) => a.alias === sortField);
|
|
2928
|
+
sortExpr = aliasEntry ? `"${sortField}"` : translator.getFieldSql(sortField);
|
|
2929
|
+
}
|
|
2930
|
+
const dir = aggOptions.sort.direction === 1 ? "ASC" : "DESC";
|
|
2931
|
+
sql += ` ORDER BY ${sortExpr} ${dir}`;
|
|
2932
|
+
}
|
|
2933
|
+
if (aggOptions.limit) {
|
|
2934
|
+
sql += ` LIMIT ${Number(aggOptions.limit)}`;
|
|
2935
|
+
}
|
|
2936
|
+
return { sql, params, aliasMap };
|
|
2937
|
+
}
|
|
2938
|
+
/** @internal — Extract operation value(s) from a row. Flattens single-op results. */
|
|
2939
|
+
_extractOpValue(row, operations) {
|
|
2940
|
+
if (operations.length === 1) {
|
|
2941
|
+
const op = operations[0];
|
|
2942
|
+
if (op.type === "count") return row.count;
|
|
2943
|
+
return row[`${op.type}_${op.field}`];
|
|
2944
|
+
}
|
|
2945
|
+
const result = {};
|
|
2946
|
+
for (const op of operations) {
|
|
2947
|
+
if (op.type === "count") {
|
|
2948
|
+
result.count = row.count;
|
|
2949
|
+
} else {
|
|
2950
|
+
result[`${op.type}_${op.field}`] = row[`${op.type}_${op.field}`];
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
return result;
|
|
2954
|
+
}
|
|
2955
|
+
/** @internal — Process raw aggregation rows into nested result */
|
|
2956
|
+
_processAggregationResults(results, aggOptions, aliasMap, stringSetFacetField) {
|
|
2957
|
+
if (results.length === 0) return {};
|
|
2958
|
+
if (stringSetFacetField !== null) {
|
|
2959
|
+
const facetResult = {};
|
|
2960
|
+
for (const row of results) {
|
|
2961
|
+
const key = row.group_key;
|
|
2962
|
+
facetResult[key] = this._extractOpValue(row, aggOptions.operations);
|
|
2963
|
+
}
|
|
2964
|
+
return facetResult;
|
|
2965
|
+
}
|
|
2966
|
+
const nestedResult = {};
|
|
2967
|
+
const aliasLookup = /* @__PURE__ */ new Map();
|
|
2968
|
+
for (const entry of aliasMap) {
|
|
2969
|
+
const membershipKey = `${entry.field}::${entry.contains}`;
|
|
2970
|
+
aliasLookup.set(membershipKey, entry.alias);
|
|
2971
|
+
}
|
|
2972
|
+
for (const row of results) {
|
|
2973
|
+
let current = nestedResult;
|
|
2974
|
+
for (let i = 0; i < aggOptions.groupBy.length; i++) {
|
|
2975
|
+
const groupBy = aggOptions.groupBy[i];
|
|
2976
|
+
let key;
|
|
2977
|
+
if (typeof groupBy === "string") {
|
|
2978
|
+
key = String(row[groupBy]);
|
|
2979
|
+
} else {
|
|
2980
|
+
const membershipKey = `${groupBy.field}::${groupBy.contains}`;
|
|
2981
|
+
const alias = aliasLookup.get(membershipKey);
|
|
2982
|
+
key = alias ? row[alias] : "unknown";
|
|
2983
|
+
}
|
|
2984
|
+
if (i === aggOptions.groupBy.length - 1) {
|
|
2985
|
+
current[key] = this._extractOpValue(row, aggOptions.operations);
|
|
2986
|
+
} else {
|
|
2987
|
+
if (!current[key]) current[key] = {};
|
|
2988
|
+
current = current[key];
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
return nestedResult;
|
|
2993
|
+
}
|
|
2994
|
+
/** @internal */
|
|
2995
|
+
/** @internal — Batch sync: compare desired indexes against _indexes table, register missing */
|
|
2996
|
+
async _handleIndexesSync(request) {
|
|
2997
|
+
const body = await request.json();
|
|
2998
|
+
if (!body.models || !Array.isArray(body.models)) {
|
|
2999
|
+
return this._errorResponse("models array is required", 400);
|
|
3000
|
+
}
|
|
3001
|
+
let registered = 0;
|
|
3002
|
+
for (const model of body.models) {
|
|
3003
|
+
if (!model.modelName) continue;
|
|
3004
|
+
const existingIndexes = this._engine.listIndexes(model.modelName);
|
|
3005
|
+
const existingIndexMap = new Map(
|
|
3006
|
+
existingIndexes.map((idx) => [idx.field_name, idx])
|
|
3007
|
+
);
|
|
3008
|
+
const existingConstraints = this._engine.listUniqueConstraints(model.modelName);
|
|
3009
|
+
const existingConstraintSet = new Set(
|
|
3010
|
+
existingConstraints.map((c) => c.constraint_name)
|
|
3011
|
+
);
|
|
3012
|
+
for (const idx of model.indexes || []) {
|
|
3013
|
+
const existing = existingIndexMap.get(idx.fieldName);
|
|
3014
|
+
if (existing) {
|
|
3015
|
+
if (idx.unique && !existing.is_unique) {
|
|
3016
|
+
this._engine.registerIndex(model.modelName, idx.fieldName, idx.fieldType, true);
|
|
3017
|
+
registered++;
|
|
3018
|
+
}
|
|
3019
|
+
continue;
|
|
3020
|
+
}
|
|
3021
|
+
this._engine.registerIndex(model.modelName, idx.fieldName, idx.fieldType, idx.unique);
|
|
3022
|
+
registered++;
|
|
3023
|
+
}
|
|
3024
|
+
for (const uc of model.uniqueConstraints || []) {
|
|
3025
|
+
if (existingConstraintSet.has(uc.name)) continue;
|
|
3026
|
+
this._engine.registerUniqueConstraint(model.modelName, uc.name, uc.fields);
|
|
3027
|
+
registered++;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
const response = { registered };
|
|
3031
|
+
return Response.json(response);
|
|
3032
|
+
}
|
|
3033
|
+
/** @internal */
|
|
3034
|
+
async _handleIndexRegister(request) {
|
|
3035
|
+
const body = await request.json();
|
|
3036
|
+
const { modelName, fieldName, fieldType, unique } = body;
|
|
3037
|
+
if (!modelName || !fieldName || !fieldType) {
|
|
3038
|
+
return this._errorResponse(
|
|
3039
|
+
"modelName, fieldName, and fieldType are required",
|
|
3040
|
+
400
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
this._engine.registerIndex(
|
|
3044
|
+
modelName,
|
|
3045
|
+
fieldName,
|
|
3046
|
+
fieldType,
|
|
3047
|
+
unique || false
|
|
3048
|
+
);
|
|
3049
|
+
const response = {
|
|
3050
|
+
success: true,
|
|
3051
|
+
modelName,
|
|
3052
|
+
fieldName
|
|
3053
|
+
};
|
|
3054
|
+
return Response.json(response);
|
|
3055
|
+
}
|
|
3056
|
+
/** @internal */
|
|
3057
|
+
async _handleIndexDrop(request) {
|
|
3058
|
+
const body = await request.json();
|
|
3059
|
+
const { modelName, fieldName } = body;
|
|
3060
|
+
if (!modelName || !fieldName) {
|
|
3061
|
+
return this._errorResponse(
|
|
3062
|
+
"modelName and fieldName are required",
|
|
3063
|
+
400
|
|
3064
|
+
);
|
|
3065
|
+
}
|
|
3066
|
+
this._engine.dropIndex(modelName, fieldName);
|
|
3067
|
+
const response = {
|
|
3068
|
+
success: true,
|
|
3069
|
+
modelName,
|
|
3070
|
+
fieldName
|
|
3071
|
+
};
|
|
3072
|
+
return Response.json(response);
|
|
3073
|
+
}
|
|
3074
|
+
/** @internal */
|
|
3075
|
+
_handleIndexList(request) {
|
|
3076
|
+
const url = new URL(request.url);
|
|
3077
|
+
const modelName = url.searchParams.get("modelName") || void 0;
|
|
3078
|
+
const indexes = this._engine.listIndexes(modelName);
|
|
3079
|
+
const response = { indexes };
|
|
3080
|
+
return Response.json(response);
|
|
3081
|
+
}
|
|
3082
|
+
/** @internal */
|
|
3083
|
+
async _handleUniqueConstraintRegister(request) {
|
|
3084
|
+
const body = await request.json();
|
|
3085
|
+
const { modelName, constraintName, fields } = body;
|
|
3086
|
+
if (!modelName || !constraintName || !fields || !Array.isArray(fields) || fields.length < 2) {
|
|
3087
|
+
return this._errorResponse(
|
|
3088
|
+
"modelName, constraintName, and fields (array with 2+ fields) are required",
|
|
3089
|
+
400
|
|
3090
|
+
);
|
|
3091
|
+
}
|
|
3092
|
+
this._engine.registerUniqueConstraint(modelName, constraintName, fields);
|
|
3093
|
+
const response = {
|
|
3094
|
+
success: true,
|
|
3095
|
+
modelName,
|
|
3096
|
+
constraintName
|
|
3097
|
+
};
|
|
3098
|
+
return Response.json(response);
|
|
3099
|
+
}
|
|
3100
|
+
/** @internal */
|
|
3101
|
+
async _handleUniqueConstraintDrop(request) {
|
|
3102
|
+
const body = await request.json();
|
|
3103
|
+
const { modelName, constraintName } = body;
|
|
3104
|
+
if (!modelName || !constraintName) {
|
|
3105
|
+
return this._errorResponse(
|
|
3106
|
+
"modelName and constraintName are required",
|
|
3107
|
+
400
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
this._engine.dropUniqueConstraint(modelName, constraintName);
|
|
3111
|
+
const response = {
|
|
3112
|
+
success: true,
|
|
3113
|
+
modelName,
|
|
3114
|
+
constraintName
|
|
3115
|
+
};
|
|
3116
|
+
return Response.json(response);
|
|
3117
|
+
}
|
|
3118
|
+
/** @internal */
|
|
3119
|
+
_handleUniqueConstraintList(request) {
|
|
3120
|
+
const url = new URL(request.url);
|
|
3121
|
+
const modelName = url.searchParams.get("modelName") || void 0;
|
|
3122
|
+
const constraints = this._engine.listUniqueConstraints(modelName);
|
|
3123
|
+
const response = { constraints };
|
|
3124
|
+
return Response.json(response);
|
|
3125
|
+
}
|
|
3126
|
+
/** @internal */
|
|
3127
|
+
/**
|
|
3128
|
+
* Extract field names that use $contains from a filter tree.
|
|
3129
|
+
* @internal
|
|
3130
|
+
*/
|
|
3131
|
+
_findContainsFields(filter) {
|
|
3132
|
+
const fields = [];
|
|
3133
|
+
if (!filter || typeof filter !== "object") return fields;
|
|
3134
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
3135
|
+
if (key === "$and" || key === "$or") {
|
|
3136
|
+
if (Array.isArray(value)) {
|
|
3137
|
+
for (const sub of value) {
|
|
3138
|
+
fields.push(...this._findContainsFields(sub));
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
} else if (value && typeof value === "object" && !Array.isArray(value) && "$contains" in value) {
|
|
3142
|
+
fields.push(key);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
return fields;
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* Check for $contains misuse with caching.
|
|
3149
|
+
* Returns an error Response if misuse is detected, or null if OK.
|
|
3150
|
+
* @internal
|
|
3151
|
+
*/
|
|
3152
|
+
_checkContainsMisuse(modelName, filter) {
|
|
3153
|
+
const containsFields = this._findContainsFields(filter);
|
|
3154
|
+
for (const field of containsFields) {
|
|
3155
|
+
assertValidIdentifier(field, "$contains field");
|
|
3156
|
+
const cacheKey = `${modelName}:${field}`;
|
|
3157
|
+
if (this._containsMisuseCache.has(cacheKey)) {
|
|
3158
|
+
return this._errorResponse(
|
|
3159
|
+
`Field '${field}' was saved as regular data, not a StringSet. To use $contains, save the field via stringSets instead of data.`,
|
|
3160
|
+
400
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
const check = this._engine.execSqlSync(
|
|
3164
|
+
`SELECT 1 FROM records WHERE _type = ? AND json_type(_data, '$.${field}') IS NOT NULL AND json_type(_data, '$.${field}') != 'array' LIMIT 1`,
|
|
3165
|
+
[modelName]
|
|
3166
|
+
);
|
|
3167
|
+
if (check.length > 0) {
|
|
3168
|
+
this._containsMisuseCache.add(cacheKey);
|
|
3169
|
+
return this._errorResponse(
|
|
3170
|
+
`Field '${field}' was saved as regular data, not a StringSet. To use $contains, save the field via stringSets instead of data.`,
|
|
3171
|
+
400
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
return null;
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Check a write condition against the current state of a record.
|
|
3179
|
+
* Returns true if the condition is met (record exists and matches the filter).
|
|
3180
|
+
* Returns false if the record doesn't exist or doesn't match.
|
|
3181
|
+
* @internal
|
|
3182
|
+
*/
|
|
3183
|
+
_checkCondition(modelName, id, condition) {
|
|
3184
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
3185
|
+
includeDocId: false
|
|
3186
|
+
});
|
|
3187
|
+
const whereClause = translator.translateFilter(condition);
|
|
3188
|
+
let sql = "SELECT 1 FROM records WHERE _id = ? AND _type = ?";
|
|
3189
|
+
const params = [id, modelName];
|
|
3190
|
+
if (whereClause.sql) {
|
|
3191
|
+
sql += ` AND ${whereClause.sql}`;
|
|
3192
|
+
params.push(...whereClause.params);
|
|
3193
|
+
}
|
|
3194
|
+
const rows = this._engine.execSqlSync(sql, params);
|
|
3195
|
+
return rows.length > 0;
|
|
3196
|
+
}
|
|
3197
|
+
_handleHealth() {
|
|
3198
|
+
return Response.json({ status: "ok" });
|
|
3199
|
+
}
|
|
3200
|
+
/** @internal — Return tracked field names and types for a model */
|
|
3201
|
+
_handleDescribe(request) {
|
|
3202
|
+
const url = new URL(request.url);
|
|
3203
|
+
const modelName = url.searchParams.get("modelName") || void 0;
|
|
3204
|
+
if (!modelName) {
|
|
3205
|
+
const body = { error: "modelName query parameter is required" };
|
|
3206
|
+
return Response.json(body, { status: 400 });
|
|
3207
|
+
}
|
|
3208
|
+
const fields = this._engine.getModelFields(modelName);
|
|
3209
|
+
const response = { modelName, fields };
|
|
3210
|
+
return Response.json(response);
|
|
3211
|
+
}
|
|
3212
|
+
/** @internal — List all known model names from records and _model_fields */
|
|
3213
|
+
_handleModelList() {
|
|
3214
|
+
const fromRecords = this._engine.execSqlSync(
|
|
3215
|
+
"SELECT DISTINCT _type FROM records ORDER BY _type"
|
|
3216
|
+
);
|
|
3217
|
+
const fromFields = this._engine.execSqlSync(
|
|
3218
|
+
"SELECT DISTINCT model_name FROM _model_fields ORDER BY model_name"
|
|
3219
|
+
);
|
|
3220
|
+
const fromIndexes = this._engine.execSqlSync(
|
|
3221
|
+
"SELECT DISTINCT model_name FROM _indexes ORDER BY model_name"
|
|
3222
|
+
);
|
|
3223
|
+
const modelSet = /* @__PURE__ */ new Set();
|
|
3224
|
+
for (const row of fromRecords) modelSet.add(row._type);
|
|
3225
|
+
for (const row of fromFields) modelSet.add(row.model_name);
|
|
3226
|
+
for (const row of fromIndexes) modelSet.add(row.model_name);
|
|
3227
|
+
return Response.json({ models: Array.from(modelSet).sort() });
|
|
3228
|
+
}
|
|
3229
|
+
/** @internal */
|
|
3230
|
+
_errorResponse(message, status) {
|
|
3231
|
+
const body = { error: message };
|
|
3232
|
+
return Response.json(body, { status });
|
|
3233
|
+
}
|
|
3234
|
+
/** @internal */
|
|
3235
|
+
_parseRow(row) {
|
|
3236
|
+
const { _id, _type, _data, ...rest } = row;
|
|
3237
|
+
let parsed = {};
|
|
3238
|
+
if (_data) {
|
|
3239
|
+
try {
|
|
3240
|
+
parsed = JSON.parse(_data);
|
|
3241
|
+
} catch (e) {
|
|
3242
|
+
console.warn("Failed to parse _data:", e);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
return { id: _id, type: _type, ...parsed, ...rest };
|
|
3246
|
+
}
|
|
3247
|
+
// ─── Include (Related Data Loading) ──────────────────────────────
|
|
3248
|
+
/** @internal — Validate an IncludeSpec, returning an error message or null */
|
|
3249
|
+
_validateIncludeSpec(spec) {
|
|
3250
|
+
if (!spec.model || typeof spec.model !== "string") {
|
|
3251
|
+
return "include: 'model' is required";
|
|
3252
|
+
}
|
|
3253
|
+
try {
|
|
3254
|
+
assertValidIdentifier(spec.model, "include model");
|
|
3255
|
+
} catch {
|
|
3256
|
+
return `include: invalid model name '${spec.model}'`;
|
|
3257
|
+
}
|
|
3258
|
+
if (spec.type !== "refersTo" && spec.type !== "hasMany" && spec.type !== "refersToMany") {
|
|
3259
|
+
return `include: 'type' must be 'refersTo', 'hasMany', or 'refersToMany', got '${spec.type}'`;
|
|
3260
|
+
}
|
|
3261
|
+
if (spec.type === "refersTo") {
|
|
3262
|
+
if (!spec.sourceField) {
|
|
3263
|
+
return "include refersTo: 'sourceField' is required";
|
|
3264
|
+
}
|
|
3265
|
+
try {
|
|
3266
|
+
assertValidIdentifier(spec.sourceField, "include sourceField");
|
|
3267
|
+
} catch {
|
|
3268
|
+
return `include refersTo: invalid sourceField '${spec.sourceField}'`;
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
if (spec.type === "refersToMany") {
|
|
3272
|
+
if (!spec.sourceField) {
|
|
3273
|
+
return "include refersToMany: 'sourceField' is required";
|
|
3274
|
+
}
|
|
3275
|
+
try {
|
|
3276
|
+
assertValidIdentifier(spec.sourceField, "include sourceField");
|
|
3277
|
+
} catch {
|
|
3278
|
+
return `include refersToMany: invalid sourceField '${spec.sourceField}'`;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
if (spec.type === "hasMany") {
|
|
3282
|
+
if (!spec.foreignKey) {
|
|
3283
|
+
return "include hasMany: 'foreignKey' is required";
|
|
3284
|
+
}
|
|
3285
|
+
try {
|
|
3286
|
+
assertValidIdentifier(spec.foreignKey, "include foreignKey");
|
|
3287
|
+
} catch {
|
|
3288
|
+
return `include hasMany: invalid foreignKey '${spec.foreignKey}'`;
|
|
3289
|
+
}
|
|
3290
|
+
if (spec.localField) {
|
|
3291
|
+
try {
|
|
3292
|
+
assertValidIdentifier(spec.localField, "include localField");
|
|
3293
|
+
} catch {
|
|
3294
|
+
return `include hasMany: invalid localField '${spec.localField}'`;
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
if (spec.as) {
|
|
3299
|
+
try {
|
|
3300
|
+
assertValidIdentifier(spec.as, "include as");
|
|
3301
|
+
} catch {
|
|
3302
|
+
return `include: invalid alias '${spec.as}'`;
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
if (spec.sort) {
|
|
3306
|
+
for (const field of Object.keys(spec.sort)) {
|
|
3307
|
+
try {
|
|
3308
|
+
assertValidIdentifier(field, "include sort field");
|
|
3309
|
+
} catch {
|
|
3310
|
+
return `include: invalid sort field '${field}'`;
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
return null;
|
|
3315
|
+
}
|
|
3316
|
+
/** @internal — Resolve all includes for a set of records */
|
|
3317
|
+
_resolveIncludes(records, includes, depth, _parentModelName) {
|
|
3318
|
+
if (records.length === 0) return records;
|
|
3319
|
+
if (depth >= 3) return records;
|
|
3320
|
+
for (const spec of includes) {
|
|
3321
|
+
const error = this._validateIncludeSpec(spec);
|
|
3322
|
+
if (error) {
|
|
3323
|
+
console.warn(`[include] Skipping invalid spec: ${error}`);
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
const resultKey = spec.as || spec.model;
|
|
3327
|
+
for (const rec of records) {
|
|
3328
|
+
if (!rec._related) rec._related = {};
|
|
3329
|
+
}
|
|
3330
|
+
if (spec.type === "refersTo") {
|
|
3331
|
+
this._resolveRefersTo(records, spec, resultKey);
|
|
3332
|
+
} else if (spec.type === "refersToMany") {
|
|
3333
|
+
this._resolveRefersToMany(records, spec, resultKey);
|
|
3334
|
+
} else {
|
|
3335
|
+
this._resolveHasMany(records, spec, resultKey);
|
|
3336
|
+
}
|
|
3337
|
+
if (spec.include && spec.include.length > 0) {
|
|
3338
|
+
const allRelated = [];
|
|
3339
|
+
for (const rec of records) {
|
|
3340
|
+
const val = rec._related[resultKey];
|
|
3341
|
+
if (Array.isArray(val)) {
|
|
3342
|
+
allRelated.push(...val);
|
|
3343
|
+
} else if (val) {
|
|
3344
|
+
allRelated.push(val);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
if (allRelated.length > 0) {
|
|
3348
|
+
this._resolveIncludes(allRelated, spec.include, depth + 1, spec.model);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
return records;
|
|
3353
|
+
}
|
|
3354
|
+
/** @internal — Resolve a refersTo include (parent FK → single related record) */
|
|
3355
|
+
_resolveRefersTo(records, spec, resultKey) {
|
|
3356
|
+
const sourceField = spec.sourceField;
|
|
3357
|
+
const uniqueValues = [
|
|
3358
|
+
...new Set(
|
|
3359
|
+
records.map((r) => r[sourceField]).filter((v) => v != null && v !== "")
|
|
3360
|
+
)
|
|
3361
|
+
];
|
|
3362
|
+
if (uniqueValues.length === 0) {
|
|
3363
|
+
for (const rec of records) {
|
|
3364
|
+
rec._related[resultKey] = null;
|
|
3365
|
+
}
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
const related = this._chunkedIn(
|
|
3369
|
+
spec.model,
|
|
3370
|
+
"id",
|
|
3371
|
+
uniqueValues,
|
|
3372
|
+
spec.filter,
|
|
3373
|
+
{ projection: spec.projection }
|
|
3374
|
+
);
|
|
3375
|
+
const lookupMap = /* @__PURE__ */ new Map();
|
|
3376
|
+
for (const r of related) {
|
|
3377
|
+
lookupMap.set(r.id, r);
|
|
3378
|
+
}
|
|
3379
|
+
for (const rec of records) {
|
|
3380
|
+
const fk = rec[sourceField];
|
|
3381
|
+
rec._related[resultKey] = fk && lookupMap.get(fk) || null;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
/** @internal — Resolve a refersToMany include (parent StringSet field → array of related records) */
|
|
3385
|
+
_resolveRefersToMany(records, spec, resultKey) {
|
|
3386
|
+
const sourceField = spec.sourceField;
|
|
3387
|
+
const allTargetIds = /* @__PURE__ */ new Set();
|
|
3388
|
+
for (const rec of records) {
|
|
3389
|
+
const values = rec[sourceField];
|
|
3390
|
+
if (Array.isArray(values)) {
|
|
3391
|
+
for (const v of values) {
|
|
3392
|
+
if (v != null && v !== "") allTargetIds.add(v);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
if (allTargetIds.size === 0) {
|
|
3397
|
+
for (const rec of records) {
|
|
3398
|
+
rec._related[resultKey] = [];
|
|
3399
|
+
}
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
const targets = this._chunkedIn(
|
|
3403
|
+
spec.model,
|
|
3404
|
+
"id",
|
|
3405
|
+
[...allTargetIds],
|
|
3406
|
+
spec.filter,
|
|
3407
|
+
{ projection: spec.projection }
|
|
3408
|
+
);
|
|
3409
|
+
const targetLookup = /* @__PURE__ */ new Map();
|
|
3410
|
+
for (const t of targets) {
|
|
3411
|
+
targetLookup.set(t.id, t);
|
|
3412
|
+
}
|
|
3413
|
+
for (const rec of records) {
|
|
3414
|
+
const values = rec[sourceField];
|
|
3415
|
+
if (Array.isArray(values)) {
|
|
3416
|
+
rec._related[resultKey] = values.map((id) => targetLookup.get(id)).filter(Boolean);
|
|
3417
|
+
} else {
|
|
3418
|
+
rec._related[resultKey] = [];
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
/** @internal — Resolve a hasMany include (target FK → array of related records) */
|
|
3423
|
+
_resolveHasMany(records, spec, resultKey) {
|
|
3424
|
+
const localField = spec.localField || "id";
|
|
3425
|
+
const uniqueValues = [
|
|
3426
|
+
...new Set(
|
|
3427
|
+
records.map((r) => r[localField]).filter((v) => v != null && v !== "")
|
|
3428
|
+
)
|
|
3429
|
+
];
|
|
3430
|
+
if (uniqueValues.length === 0) {
|
|
3431
|
+
for (const rec of records) {
|
|
3432
|
+
rec._related[resultKey] = [];
|
|
3433
|
+
}
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
let related;
|
|
3437
|
+
if (spec.limit) {
|
|
3438
|
+
related = this._resolveHasManyWithLimit(spec, uniqueValues);
|
|
3439
|
+
} else {
|
|
3440
|
+
related = this._resolveHasManySimple(spec, uniqueValues);
|
|
3441
|
+
}
|
|
3442
|
+
const foreignKey = spec.foreignKey;
|
|
3443
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3444
|
+
for (const r of related) {
|
|
3445
|
+
const fk = r[foreignKey];
|
|
3446
|
+
if (!grouped.has(fk)) grouped.set(fk, []);
|
|
3447
|
+
grouped.get(fk).push(r);
|
|
3448
|
+
}
|
|
3449
|
+
if (spec.projection) {
|
|
3450
|
+
for (const [key, list] of grouped) {
|
|
3451
|
+
grouped.set(
|
|
3452
|
+
key,
|
|
3453
|
+
list.map((r) => this._applyProjection(r, spec.projection))
|
|
3454
|
+
);
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
for (const rec of records) {
|
|
3458
|
+
rec._related[resultKey] = grouped.get(rec[localField]) || [];
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
/** @internal — Simple hasMany without per-parent limit */
|
|
3462
|
+
_resolveHasManySimple(spec, parentValues) {
|
|
3463
|
+
return this._chunkedIn(
|
|
3464
|
+
spec.model,
|
|
3465
|
+
spec.foreignKey,
|
|
3466
|
+
parentValues,
|
|
3467
|
+
spec.filter,
|
|
3468
|
+
{ sort: spec.sort }
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
/** @internal — hasMany with per-parent limit using ROW_NUMBER() */
|
|
3472
|
+
_resolveHasManyWithLimit(spec, parentValues) {
|
|
3473
|
+
const foreignKey = spec.foreignKey;
|
|
3474
|
+
assertValidIdentifier(foreignKey, "include foreignKey");
|
|
3475
|
+
let sortClause = `"_id" ASC`;
|
|
3476
|
+
if (spec.sort) {
|
|
3477
|
+
const parts = [];
|
|
3478
|
+
for (const [field, dir] of Object.entries(spec.sort)) {
|
|
3479
|
+
assertValidIdentifier(field, "include sort field");
|
|
3480
|
+
const fieldSql = field === "id" ? `"_id"` : field === "type" ? `"_type"` : `json_extract(_data, '$.${field}')`;
|
|
3481
|
+
parts.push(`${fieldSql} ${dir === -1 ? "DESC" : "ASC"}`);
|
|
3482
|
+
}
|
|
3483
|
+
sortClause = parts.join(", ");
|
|
3484
|
+
}
|
|
3485
|
+
const translator = new JsonQueryTranslator(spec.model, void 0, {
|
|
3486
|
+
includeDocId: false
|
|
3487
|
+
});
|
|
3488
|
+
let filterClause = "";
|
|
3489
|
+
let filterParams = [];
|
|
3490
|
+
if (spec.filter) {
|
|
3491
|
+
const translated = translator.translateFilter(spec.filter);
|
|
3492
|
+
if (translated.sql) {
|
|
3493
|
+
filterClause = ` AND ${translated.sql}`;
|
|
3494
|
+
filterParams = translated.params;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
const reservedParams = 1 + filterParams.length + 1;
|
|
3498
|
+
const chunkSize = Math.max(1, 100 - reservedParams);
|
|
3499
|
+
const allResults = [];
|
|
3500
|
+
for (let i = 0; i < parentValues.length; i += chunkSize) {
|
|
3501
|
+
const chunk = parentValues.slice(i, i + chunkSize);
|
|
3502
|
+
const inPlaceholders = chunk.map(() => "?").join(", ");
|
|
3503
|
+
const sql = `
|
|
3504
|
+
SELECT "_id", "_type", _data FROM (
|
|
3505
|
+
SELECT "_id", "_type", _data,
|
|
3506
|
+
ROW_NUMBER() OVER (
|
|
3507
|
+
PARTITION BY json_extract(_data, '$.${foreignKey}')
|
|
3508
|
+
ORDER BY ${sortClause}
|
|
3509
|
+
) as _rn
|
|
3510
|
+
FROM records
|
|
3511
|
+
WHERE "_type" = ?
|
|
3512
|
+
AND json_extract(_data, '$.${foreignKey}') IN (${inPlaceholders})
|
|
3513
|
+
${filterClause}
|
|
3514
|
+
) WHERE _rn <= ?
|
|
3515
|
+
`;
|
|
3516
|
+
const params = [spec.model, ...chunk, ...filterParams, spec.limit];
|
|
3517
|
+
const rows = this._engine.execSqlSync(sql, params);
|
|
3518
|
+
allResults.push(...rows.map((row) => this._parseRow(row)));
|
|
3519
|
+
}
|
|
3520
|
+
return allResults;
|
|
3521
|
+
}
|
|
3522
|
+
/**
|
|
3523
|
+
* @internal — Execute IN queries in chunks to stay under Cloudflare DO's
|
|
3524
|
+
* 100 bound parameter limit per SQL statement.
|
|
3525
|
+
*/
|
|
3526
|
+
_chunkedIn(model, field, values, extraFilter, extraOptions) {
|
|
3527
|
+
const translator = new JsonQueryTranslator(model, void 0, {
|
|
3528
|
+
includeDocId: false
|
|
3529
|
+
});
|
|
3530
|
+
let filterParamCount = 0;
|
|
3531
|
+
if (extraFilter) {
|
|
3532
|
+
const translated = translator.translateFilter(extraFilter);
|
|
3533
|
+
filterParamCount = translated.params.length;
|
|
3534
|
+
}
|
|
3535
|
+
const reservedParams = 1 + filterParamCount;
|
|
3536
|
+
const chunkSize = Math.max(1, 100 - reservedParams);
|
|
3537
|
+
const allResults = [];
|
|
3538
|
+
for (let i = 0; i < values.length; i += chunkSize) {
|
|
3539
|
+
const chunk = values.slice(i, i + chunkSize);
|
|
3540
|
+
const filter = {
|
|
3541
|
+
[field]: { $in: chunk },
|
|
3542
|
+
...extraFilter || {}
|
|
3543
|
+
};
|
|
3544
|
+
const options = {};
|
|
3545
|
+
if (extraOptions?.sort) options.sort = extraOptions.sort;
|
|
3546
|
+
if (extraOptions?.projection) options.projection = extraOptions.projection;
|
|
3547
|
+
const { sql, params } = translator.translateFind(filter, options);
|
|
3548
|
+
const rows = this._engine.execSqlSync(sql, params);
|
|
3549
|
+
allResults.push(...rows.map((row) => this._parseRow(row)));
|
|
3550
|
+
}
|
|
3551
|
+
return allResults;
|
|
3552
|
+
}
|
|
3553
|
+
/** @internal — Apply projection to a single record */
|
|
3554
|
+
_applyProjection(record, projection) {
|
|
3555
|
+
const fields = Object.entries(projection);
|
|
3556
|
+
if (fields.length === 0) return record;
|
|
3557
|
+
const isInclusion = fields.some(([, v]) => v === 1);
|
|
3558
|
+
if (isInclusion) {
|
|
3559
|
+
const result = {};
|
|
3560
|
+
if (record.id !== void 0) result.id = record.id;
|
|
3561
|
+
if (record.type !== void 0) result.type = record.type;
|
|
3562
|
+
if (record._related !== void 0) result._related = record._related;
|
|
3563
|
+
for (const [f, v] of fields) {
|
|
3564
|
+
if (v === 1 && record[f] !== void 0) {
|
|
3565
|
+
result[f] = record[f];
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
return result;
|
|
3569
|
+
} else {
|
|
3570
|
+
const result = { ...record };
|
|
3571
|
+
for (const [f, v] of fields) {
|
|
3572
|
+
if (v === 0) delete result[f];
|
|
3573
|
+
}
|
|
3574
|
+
return result;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
}
|
|
3579
|
+
var createDocumentDO = createDatabaseDO;
|
|
3580
|
+
|
|
3581
|
+
// src/engines/cloudflare/worker.template.ts
|
|
3582
|
+
var CORS_HEADERS = {
|
|
3583
|
+
"Access-Control-Allow-Origin": "*",
|
|
3584
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
3585
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
3586
|
+
};
|
|
3587
|
+
function handleOptions() {
|
|
3588
|
+
return new Response(null, {
|
|
3589
|
+
status: 204,
|
|
3590
|
+
headers: CORS_HEADERS
|
|
3591
|
+
});
|
|
3592
|
+
}
|
|
3593
|
+
function addCorsHeaders(response) {
|
|
3594
|
+
const newHeaders = new Headers(response.headers);
|
|
3595
|
+
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
|
3596
|
+
newHeaders.set(key, value);
|
|
3597
|
+
}
|
|
3598
|
+
return new Response(response.body, {
|
|
3599
|
+
status: response.status,
|
|
3600
|
+
statusText: response.statusText,
|
|
3601
|
+
headers: newHeaders
|
|
3602
|
+
});
|
|
3603
|
+
}
|
|
3604
|
+
function getDocumentStub(env, docId) {
|
|
3605
|
+
const doId = env.DOCUMENT_DO.idFromName(docId);
|
|
3606
|
+
return env.DOCUMENT_DO.get(doId);
|
|
3607
|
+
}
|
|
3608
|
+
async function handleRequest(request, env) {
|
|
3609
|
+
if (request.method === "OPTIONS") {
|
|
3610
|
+
return handleOptions();
|
|
3611
|
+
}
|
|
3612
|
+
const url = new URL(request.url);
|
|
3613
|
+
let docId = url.searchParams.get("docId");
|
|
3614
|
+
if (!docId) {
|
|
3615
|
+
const pathMatch = url.pathname.match(/^\/docs\/([^\/]+)\/(.+)$/);
|
|
3616
|
+
if (pathMatch) {
|
|
3617
|
+
docId = pathMatch[1];
|
|
3618
|
+
url.pathname = "/" + pathMatch[2];
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
if (!docId) {
|
|
3622
|
+
return addCorsHeaders(
|
|
3623
|
+
Response.json(
|
|
3624
|
+
{ error: "Missing docId parameter" },
|
|
3625
|
+
{ status: 400 }
|
|
3626
|
+
)
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
const stub = getDocumentStub(env, docId);
|
|
3630
|
+
const doRequest = new Request(url.toString(), {
|
|
3631
|
+
method: request.method,
|
|
3632
|
+
headers: request.headers,
|
|
3633
|
+
body: request.body
|
|
3634
|
+
});
|
|
3635
|
+
const response = await stub.fetch(doRequest);
|
|
3636
|
+
return addCorsHeaders(response);
|
|
3637
|
+
}
|