js-bao 0.2.10 → 0.3.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/browser.cjs +2 -2
- package/dist/browser.js +2 -2
- package/dist/client.cjs +766 -0
- package/dist/client.d.cts +1084 -0
- package/dist/client.d.ts +1084 -0
- package/dist/client.js +733 -0
- package/dist/cloudflare-do.cjs +3168 -0
- package/dist/cloudflare-do.d.cts +1303 -0
- package/dist/cloudflare-do.d.ts +1303 -0
- package/dist/cloudflare-do.js +3145 -0
- package/dist/cloudflare.cjs +977 -0
- package/dist/cloudflare.d.cts +1349 -0
- package/dist/cloudflare.d.ts +1349 -0
- package/dist/cloudflare.js +944 -0
- package/dist/codegen.cjs +258 -17
- package/dist/index.cjs +2 -2
- package/dist/index.js +2 -2
- package/dist/node.cjs +2 -2
- package/dist/node.js +2 -2
- package/package.json +40 -11
|
@@ -0,0 +1,3168 @@
|
|
|
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_json 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_json 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_json, '$.${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_json, _meta_doc_id, _meta_permission_hint)
|
|
238
|
+
VALUES (?, ?, ?, ?, ?)`.trim();
|
|
239
|
+
} else {
|
|
240
|
+
return `
|
|
241
|
+
INSERT OR REPLACE INTO records (id, type, data_json)
|
|
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, ...rest } = data;
|
|
324
|
+
const { type: _type, ...fieldsForJson } = rest;
|
|
325
|
+
const dataJson = JSON.stringify(fieldsForJson);
|
|
326
|
+
const sql = JsonSchemaDDL.buildInsertSQL(this.schemaOptions);
|
|
327
|
+
if (this.schemaOptions.includeDocIdColumn) {
|
|
328
|
+
await this.execSql(sql, [
|
|
329
|
+
id,
|
|
330
|
+
modelName,
|
|
331
|
+
dataJson,
|
|
332
|
+
_meta_doc_id || null,
|
|
333
|
+
_meta_permission_hint || null
|
|
334
|
+
]);
|
|
335
|
+
} else {
|
|
336
|
+
await this.execSql(sql, [id, modelName, dataJson]);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Delete a record by ID.
|
|
341
|
+
*/
|
|
342
|
+
async delete(modelName, id) {
|
|
343
|
+
const sql = JsonSchemaDDL.buildDeleteSQL(this.schemaOptions);
|
|
344
|
+
await this.execSql(sql, [modelName, id]);
|
|
345
|
+
const ssDeleteSql = JsonSchemaDDL.buildDeleteStringSetSQL();
|
|
346
|
+
await this.execSql(ssDeleteSql, [modelName, id]);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Delete all records for a specific document.
|
|
350
|
+
* Only applicable in yjs mode with doc ID column.
|
|
351
|
+
*/
|
|
352
|
+
async deleteByDocumentId(modelName, docId) {
|
|
353
|
+
if (!this.schemaOptions.includeDocIdColumn) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
"deleteByDocumentId is only supported in yjs mode with doc ID column"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
await this.execSql(
|
|
359
|
+
"DELETE FROM records WHERE type = ? AND _meta_doc_id = ?",
|
|
360
|
+
[modelName, docId]
|
|
361
|
+
);
|
|
362
|
+
await this.execSql(
|
|
363
|
+
"DELETE FROM stringset_index WHERE type = ? AND _meta_doc_id = ?",
|
|
364
|
+
[modelName, docId]
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Insert StringSet values for a record.
|
|
369
|
+
*/
|
|
370
|
+
async insertStringSetValues(modelName, fieldName, recordId, values, docId) {
|
|
371
|
+
if (values.length === 0) return;
|
|
372
|
+
const sql = JsonSchemaDDL.buildInsertStringSetSQL(this.schemaOptions);
|
|
373
|
+
for (const value of values) {
|
|
374
|
+
if (this.schemaOptions.includeDocIdColumn) {
|
|
375
|
+
await this.execSql(sql, [
|
|
376
|
+
recordId,
|
|
377
|
+
modelName,
|
|
378
|
+
fieldName,
|
|
379
|
+
value,
|
|
380
|
+
docId || null
|
|
381
|
+
]);
|
|
382
|
+
} else {
|
|
383
|
+
await this.execSql(sql, [recordId, modelName, fieldName, value]);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Remove StringSet values for a record.
|
|
389
|
+
*/
|
|
390
|
+
async removeStringSetValues(modelName, fieldName, recordId, values) {
|
|
391
|
+
if (values.length === 0) return;
|
|
392
|
+
const placeholders = values.map(() => "?").join(", ");
|
|
393
|
+
const sql = `DELETE FROM stringset_index WHERE type = ? AND field = ? AND record_id = ? AND value IN (${placeholders})`;
|
|
394
|
+
await this.execSql(sql, [modelName, fieldName, recordId, ...values]);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Get table name for a model.
|
|
398
|
+
* In JSON schema, all models use the 'records' table.
|
|
399
|
+
*/
|
|
400
|
+
getTableName(_modelName) {
|
|
401
|
+
return "records";
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Execute a query and return results.
|
|
405
|
+
*/
|
|
406
|
+
async query(sql, params) {
|
|
407
|
+
return this.execSql(sql, params);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Get the table schema.
|
|
411
|
+
* Returns information about the records table structure.
|
|
412
|
+
*/
|
|
413
|
+
async getTableSchema(_tableName) {
|
|
414
|
+
return this.execSql("PRAGMA table_info(records)");
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Get the last error message.
|
|
418
|
+
* Subclasses can override this if needed.
|
|
419
|
+
*/
|
|
420
|
+
getLastErrorMessage() {
|
|
421
|
+
return void 0;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Transaction support.
|
|
425
|
+
* Default implementation runs callback without actual transaction.
|
|
426
|
+
* Subclasses should override for proper transaction support.
|
|
427
|
+
*/
|
|
428
|
+
async withTransaction(callback) {
|
|
429
|
+
const ops = {
|
|
430
|
+
insert: async (modelName, data) => {
|
|
431
|
+
await this.insert(modelName, data);
|
|
432
|
+
},
|
|
433
|
+
delete: async (modelName, id) => {
|
|
434
|
+
await this.delete(modelName, id);
|
|
435
|
+
},
|
|
436
|
+
query: async (sql, params) => {
|
|
437
|
+
return await this.execSql(sql, params);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
return callback(ops);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Parse a record row from the database.
|
|
444
|
+
* Extracts fields from data_json and merges with direct columns.
|
|
445
|
+
*/
|
|
446
|
+
parseRecordRow(row) {
|
|
447
|
+
if (!row) return row;
|
|
448
|
+
const { id, type, data_json, _meta_doc_id, _meta_permission_hint, ...rest } = row;
|
|
449
|
+
let parsedData = {};
|
|
450
|
+
if (data_json) {
|
|
451
|
+
try {
|
|
452
|
+
parsedData = JSON.parse(data_json);
|
|
453
|
+
} catch (e) {
|
|
454
|
+
console.warn("Failed to parse data_json:", e);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const result = {
|
|
458
|
+
id,
|
|
459
|
+
type,
|
|
460
|
+
...parsedData,
|
|
461
|
+
...rest
|
|
462
|
+
// Include any projected fields
|
|
463
|
+
};
|
|
464
|
+
if (_meta_doc_id !== void 0) {
|
|
465
|
+
result._meta_doc_id = _meta_doc_id;
|
|
466
|
+
}
|
|
467
|
+
if (_meta_permission_hint !== void 0) {
|
|
468
|
+
result._meta_permission_hint = _meta_permission_hint;
|
|
469
|
+
}
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/engines/cloudflare/DurableObjectEngine.ts
|
|
475
|
+
var DurableObjectEngine = class extends JsonSchemaEngine {
|
|
476
|
+
sql;
|
|
477
|
+
storage;
|
|
478
|
+
constructor(options) {
|
|
479
|
+
super({ includeDocIdColumn: false });
|
|
480
|
+
this.sql = options.sql;
|
|
481
|
+
this.storage = options.storage;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Initialize the engine — creates tables and restores persisted indexes.
|
|
485
|
+
*/
|
|
486
|
+
async ensureReady() {
|
|
487
|
+
await this.initializeSchema();
|
|
488
|
+
this._migrateSchema();
|
|
489
|
+
this.loadPersistedIndexes();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Handle schema migrations for existing databases.
|
|
493
|
+
* Adds columns/tables that were added in later versions.
|
|
494
|
+
*/
|
|
495
|
+
_migrateSchema() {
|
|
496
|
+
try {
|
|
497
|
+
this.execSqlSync(
|
|
498
|
+
"ALTER TABLE _indexes ADD COLUMN is_unique INTEGER DEFAULT 0"
|
|
499
|
+
);
|
|
500
|
+
} catch (_e) {
|
|
501
|
+
}
|
|
502
|
+
this.execSqlSync(`
|
|
503
|
+
CREATE TABLE IF NOT EXISTS _model_fields (
|
|
504
|
+
model_name TEXT NOT NULL,
|
|
505
|
+
field_name TEXT NOT NULL,
|
|
506
|
+
inferred_type TEXT NOT NULL,
|
|
507
|
+
first_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
508
|
+
PRIMARY KEY (model_name, field_name)
|
|
509
|
+
)
|
|
510
|
+
`);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Load indexes from the _indexes table and re-ensure they exist.
|
|
514
|
+
*/
|
|
515
|
+
loadPersistedIndexes() {
|
|
516
|
+
try {
|
|
517
|
+
const rows = this.execSqlSync(
|
|
518
|
+
"SELECT model_name, field_name FROM _indexes"
|
|
519
|
+
);
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
const createSql = JsonSchemaDDL.createFieldIndex(
|
|
522
|
+
row.model_name,
|
|
523
|
+
row.field_name
|
|
524
|
+
);
|
|
525
|
+
this.execSqlSync(createSql);
|
|
526
|
+
}
|
|
527
|
+
} catch (e) {
|
|
528
|
+
console.debug("Could not load persisted indexes:", e);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Register an index on a model field.
|
|
533
|
+
* Idempotent — re-registering the same index is a no-op.
|
|
534
|
+
*/
|
|
535
|
+
registerIndex(modelName, fieldName, fieldType, unique = false) {
|
|
536
|
+
assertValidIdentifier(modelName, "registerIndex modelName");
|
|
537
|
+
assertValidIdentifier(fieldName, "registerIndex fieldName");
|
|
538
|
+
this.execSqlSync(
|
|
539
|
+
`INSERT OR REPLACE INTO _indexes (model_name, field_name, field_type, is_unique)
|
|
540
|
+
VALUES (?, ?, ?, ?)`,
|
|
541
|
+
[modelName, fieldName, fieldType, unique ? 1 : 0]
|
|
542
|
+
);
|
|
543
|
+
const createSql = JsonSchemaDDL.createFieldIndex(modelName, fieldName);
|
|
544
|
+
this.execSqlSync(createSql);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Drop an index on a model field.
|
|
548
|
+
* Idempotent — dropping a non-existent index is a no-op.
|
|
549
|
+
*/
|
|
550
|
+
dropIndex(modelName, fieldName) {
|
|
551
|
+
assertValidIdentifier(modelName, "dropIndex modelName");
|
|
552
|
+
assertValidIdentifier(fieldName, "dropIndex fieldName");
|
|
553
|
+
this.execSqlSync(
|
|
554
|
+
"DELETE FROM _indexes WHERE model_name = ? AND field_name = ?",
|
|
555
|
+
[modelName, fieldName]
|
|
556
|
+
);
|
|
557
|
+
const dropSql = JsonSchemaDDL.dropFieldIndex(modelName, fieldName);
|
|
558
|
+
this.execSqlSync(dropSql);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* List all registered indexes, optionally filtered by model.
|
|
562
|
+
*/
|
|
563
|
+
listIndexes(modelName) {
|
|
564
|
+
if (modelName) {
|
|
565
|
+
return this.execSqlSync(
|
|
566
|
+
"SELECT model_name, field_name, field_type, is_unique, created_at FROM _indexes WHERE model_name = ?",
|
|
567
|
+
[modelName]
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
return this.execSqlSync(
|
|
571
|
+
"SELECT model_name, field_name, field_type, is_unique, created_at FROM _indexes"
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Get unique single-field indexes for a model.
|
|
576
|
+
*/
|
|
577
|
+
getUniqueIndexes(modelName) {
|
|
578
|
+
return this.execSqlSync(
|
|
579
|
+
"SELECT model_name, field_name, field_type, is_unique, created_at FROM _indexes WHERE model_name = ? AND is_unique = 1",
|
|
580
|
+
[modelName]
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Register a composite unique constraint.
|
|
585
|
+
* Idempotent — re-registering the same constraint is a no-op.
|
|
586
|
+
*/
|
|
587
|
+
registerUniqueConstraint(modelName, constraintName, fields) {
|
|
588
|
+
assertValidIdentifier(modelName, "registerUniqueConstraint modelName");
|
|
589
|
+
assertValidIdentifier(constraintName, "registerUniqueConstraint constraintName");
|
|
590
|
+
for (const f of fields) {
|
|
591
|
+
assertValidIdentifier(f, "registerUniqueConstraint field");
|
|
592
|
+
}
|
|
593
|
+
this.execSqlSync(
|
|
594
|
+
`INSERT OR REPLACE INTO _unique_constraints (model_name, constraint_name, fields_json)
|
|
595
|
+
VALUES (?, ?, ?)`,
|
|
596
|
+
[modelName, constraintName, JSON.stringify(fields)]
|
|
597
|
+
);
|
|
598
|
+
const fieldExprs = fields.map((f) => `json_extract(data_json, '$.${f}')`).join(", ");
|
|
599
|
+
const indexName = `idx_uc_${modelName.toLowerCase()}_${constraintName.toLowerCase()}`;
|
|
600
|
+
this.execSqlSync(
|
|
601
|
+
`CREATE INDEX IF NOT EXISTS ${indexName} ON records(${fieldExprs}) WHERE type = '${modelName}'`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Drop a composite unique constraint.
|
|
606
|
+
* Idempotent — dropping a non-existent constraint is a no-op.
|
|
607
|
+
*/
|
|
608
|
+
dropUniqueConstraint(modelName, constraintName) {
|
|
609
|
+
this.execSqlSync(
|
|
610
|
+
"DELETE FROM _unique_constraints WHERE model_name = ? AND constraint_name = ?",
|
|
611
|
+
[modelName, constraintName]
|
|
612
|
+
);
|
|
613
|
+
const indexName = `idx_uc_${modelName.toLowerCase()}_${constraintName.toLowerCase()}`;
|
|
614
|
+
this.execSqlSync(`DROP INDEX IF EXISTS ${indexName}`);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* List composite unique constraints, optionally filtered by model.
|
|
618
|
+
*/
|
|
619
|
+
listUniqueConstraints(modelName) {
|
|
620
|
+
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";
|
|
621
|
+
const params = modelName ? [modelName] : void 0;
|
|
622
|
+
const rows = this.execSqlSync(sql, params);
|
|
623
|
+
return rows.map((row) => ({
|
|
624
|
+
model_name: row.model_name,
|
|
625
|
+
constraint_name: row.constraint_name,
|
|
626
|
+
fields: JSON.parse(row.fields_json),
|
|
627
|
+
created_at: row.created_at
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Check all unique constraints for a model before saving.
|
|
632
|
+
* Returns null if no violation, or an error message string if violated.
|
|
633
|
+
*/
|
|
634
|
+
checkUniqueConstraints(modelName, id, data) {
|
|
635
|
+
const uniqueIndexes = this.getUniqueIndexes(modelName);
|
|
636
|
+
for (const idx of uniqueIndexes) {
|
|
637
|
+
assertValidIdentifier(idx.field_name, "checkUniqueConstraints field_name");
|
|
638
|
+
const value = data[idx.field_name];
|
|
639
|
+
if (value === null || value === void 0) continue;
|
|
640
|
+
const conflicts = this.execSqlSync(
|
|
641
|
+
`SELECT id FROM records WHERE type = ? AND json_extract(data_json, '$.${idx.field_name}') = ? AND id != ? LIMIT 1`,
|
|
642
|
+
[modelName, value, id]
|
|
643
|
+
);
|
|
644
|
+
if (conflicts.length > 0) {
|
|
645
|
+
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}'.`;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const composites = this.listUniqueConstraints(modelName);
|
|
649
|
+
for (const constraint of composites) {
|
|
650
|
+
for (const f of constraint.fields) {
|
|
651
|
+
assertValidIdentifier(f, "checkUniqueConstraints composite field");
|
|
652
|
+
}
|
|
653
|
+
const values = constraint.fields.map((f) => data[f]);
|
|
654
|
+
if (values.some((v) => v === null || v === void 0)) continue;
|
|
655
|
+
const conditions = constraint.fields.map((f) => `json_extract(data_json, '$.${f}') = ?`).join(" AND ");
|
|
656
|
+
const conflicts = this.execSqlSync(
|
|
657
|
+
`SELECT id FROM records WHERE type = ? AND ${conditions} AND id != ? LIMIT 1`,
|
|
658
|
+
[modelName, ...values, id]
|
|
659
|
+
);
|
|
660
|
+
if (conflicts.length > 0) {
|
|
661
|
+
return `Unique constraint '${constraint.constraint_name}' violated for model '${modelName}' on fields [${constraint.fields.join(", ")}]. Values already exist on record '${conflicts[0].id}'.`;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Check if a record exists (used by hooks to determine isNew).
|
|
668
|
+
*/
|
|
669
|
+
recordExists(modelName, id) {
|
|
670
|
+
const rows = this.execSqlSync(
|
|
671
|
+
"SELECT 1 FROM records WHERE type = ? AND id = ? LIMIT 1",
|
|
672
|
+
[modelName, id]
|
|
673
|
+
);
|
|
674
|
+
return rows.length > 0;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Execute SQL asynchronously.
|
|
678
|
+
* Wraps the synchronous DO SQLite API.
|
|
679
|
+
*/
|
|
680
|
+
async execSql(sql, params) {
|
|
681
|
+
return this.execSqlSync(sql, params);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Execute SQL synchronously.
|
|
685
|
+
* This is the native mode for DO SQLite.
|
|
686
|
+
*/
|
|
687
|
+
execSqlSync(sql, params) {
|
|
688
|
+
try {
|
|
689
|
+
const cursor = params ? this.sql.exec(sql, ...params) : this.sql.exec(sql);
|
|
690
|
+
return cursor.toArray();
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error("SQL execution error:", error);
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Transaction support using DO's transactionSync.
|
|
698
|
+
*/
|
|
699
|
+
async withTransaction(callback) {
|
|
700
|
+
const ops = {
|
|
701
|
+
insert: async (modelName, data) => {
|
|
702
|
+
await this.insert(modelName, data);
|
|
703
|
+
},
|
|
704
|
+
delete: async (modelName, id) => {
|
|
705
|
+
await this.delete(modelName, id);
|
|
706
|
+
},
|
|
707
|
+
query: async (sql, params) => {
|
|
708
|
+
return this.execSqlSync(sql, params);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
return callback(ops);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Run operations within a synchronous transaction.
|
|
715
|
+
*/
|
|
716
|
+
transactionSync(callback) {
|
|
717
|
+
return this.storage.transactionSync(callback);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Clean up resources.
|
|
721
|
+
*/
|
|
722
|
+
async destroy() {
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Insert a record with StringSet support.
|
|
726
|
+
*/
|
|
727
|
+
async insertWithStringSets(modelName, data, stringSets) {
|
|
728
|
+
this.storage.transactionSync(() => {
|
|
729
|
+
const { id, ...rest } = data;
|
|
730
|
+
const { type: _type, ...fieldsForJson } = rest;
|
|
731
|
+
const jsonFields = { ...fieldsForJson, ...stringSets };
|
|
732
|
+
const dataJson = JSON.stringify(jsonFields);
|
|
733
|
+
this.sql.exec(
|
|
734
|
+
"INSERT OR REPLACE INTO records (id, type, data_json) VALUES (?, ?, ?)",
|
|
735
|
+
id,
|
|
736
|
+
modelName,
|
|
737
|
+
dataJson
|
|
738
|
+
);
|
|
739
|
+
this.sql.exec(
|
|
740
|
+
"DELETE FROM stringset_index WHERE type = ? AND record_id = ?",
|
|
741
|
+
modelName,
|
|
742
|
+
id
|
|
743
|
+
);
|
|
744
|
+
for (const [fieldName, values] of Object.entries(stringSets)) {
|
|
745
|
+
for (const value of values) {
|
|
746
|
+
this.sql.exec(
|
|
747
|
+
"INSERT OR IGNORE INTO stringset_index (record_id, type, field, value) VALUES (?, ?, ?, ?)",
|
|
748
|
+
id,
|
|
749
|
+
modelName,
|
|
750
|
+
fieldName,
|
|
751
|
+
value
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Add values to StringSet fields without replacing the entire set.
|
|
759
|
+
* Also updates the arrays stored in data_json.
|
|
760
|
+
*/
|
|
761
|
+
addToStringSets(modelName, id, sets) {
|
|
762
|
+
this.storage.transactionSync(() => {
|
|
763
|
+
this.addToStringSetsRaw(modelName, id, sets);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Non-transactional core of addToStringSets.
|
|
768
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
769
|
+
* @internal
|
|
770
|
+
*/
|
|
771
|
+
addToStringSetsRaw(modelName, id, sets) {
|
|
772
|
+
for (const [field, values] of Object.entries(sets)) {
|
|
773
|
+
for (const value of values) {
|
|
774
|
+
this.sql.exec(
|
|
775
|
+
"INSERT OR IGNORE INTO stringset_index (record_id, type, field, value) VALUES (?, ?, ?, ?)",
|
|
776
|
+
id,
|
|
777
|
+
modelName,
|
|
778
|
+
field,
|
|
779
|
+
value
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
this._syncStringSetsToJson(modelName, id, Object.keys(sets));
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Remove values from StringSet fields without replacing the entire set.
|
|
787
|
+
* Also updates the arrays stored in data_json.
|
|
788
|
+
*/
|
|
789
|
+
removeFromStringSets(modelName, id, sets) {
|
|
790
|
+
this.storage.transactionSync(() => {
|
|
791
|
+
this.removeFromStringSetsRaw(modelName, id, sets);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Non-transactional core of removeFromStringSets.
|
|
796
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
797
|
+
* @internal
|
|
798
|
+
*/
|
|
799
|
+
removeFromStringSetsRaw(modelName, id, sets) {
|
|
800
|
+
for (const [field, values] of Object.entries(sets)) {
|
|
801
|
+
for (const value of values) {
|
|
802
|
+
this.sql.exec(
|
|
803
|
+
"DELETE FROM stringset_index WHERE record_id = ? AND type = ? AND field = ? AND value = ?",
|
|
804
|
+
id,
|
|
805
|
+
modelName,
|
|
806
|
+
field,
|
|
807
|
+
value
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
this._syncStringSetsToJson(modelName, id, Object.keys(sets));
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Sync stringset_index state back into data_json arrays for the given fields.
|
|
815
|
+
* @internal
|
|
816
|
+
*/
|
|
817
|
+
_syncStringSetsToJson(modelName, id, fields) {
|
|
818
|
+
const rows = this.execSqlSync(
|
|
819
|
+
"SELECT data_json FROM records WHERE id = ? AND type = ?",
|
|
820
|
+
[id, modelName]
|
|
821
|
+
);
|
|
822
|
+
if (rows.length === 0) return;
|
|
823
|
+
const data = JSON.parse(rows[0].data_json);
|
|
824
|
+
for (const field of fields) {
|
|
825
|
+
const ssRows = this.execSqlSync(
|
|
826
|
+
"SELECT value FROM stringset_index WHERE record_id = ? AND type = ? AND field = ? ORDER BY value",
|
|
827
|
+
[id, modelName, field]
|
|
828
|
+
);
|
|
829
|
+
data[field] = ssRows.map((r) => r.value);
|
|
830
|
+
}
|
|
831
|
+
this.sql.exec(
|
|
832
|
+
"UPDATE records SET data_json = ? WHERE id = ? AND type = ?",
|
|
833
|
+
JSON.stringify(data),
|
|
834
|
+
id,
|
|
835
|
+
modelName
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Patch a record: merge provided fields into existing data_json.
|
|
840
|
+
* Only the specified fields are updated; all other fields are preserved.
|
|
841
|
+
* If stringSets are provided, those StringSet fields are fully replaced.
|
|
842
|
+
* Returns false if the record does not exist.
|
|
843
|
+
*/
|
|
844
|
+
patchRecord(modelName, id, fields, stringSets) {
|
|
845
|
+
let found = true;
|
|
846
|
+
this.storage.transactionSync(() => {
|
|
847
|
+
found = this.patchRecordRaw(modelName, id, fields, stringSets);
|
|
848
|
+
});
|
|
849
|
+
return found;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Non-transactional core of patchRecord.
|
|
853
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
854
|
+
* @internal
|
|
855
|
+
*/
|
|
856
|
+
patchRecordRaw(modelName, id, fields, stringSets) {
|
|
857
|
+
const rows = this.execSqlSync(
|
|
858
|
+
"SELECT data_json FROM records WHERE id = ? AND type = ?",
|
|
859
|
+
[id, modelName]
|
|
860
|
+
);
|
|
861
|
+
if (rows.length === 0) {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
const existing = JSON.parse(rows[0].data_json);
|
|
865
|
+
const { id: _id, type: _type, ...patchFields } = fields;
|
|
866
|
+
const merged = { ...existing, ...patchFields };
|
|
867
|
+
if (stringSets) {
|
|
868
|
+
for (const [field, values] of Object.entries(stringSets)) {
|
|
869
|
+
merged[field] = values;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
this.sql.exec(
|
|
873
|
+
"UPDATE records SET data_json = ? WHERE id = ? AND type = ?",
|
|
874
|
+
JSON.stringify(merged),
|
|
875
|
+
id,
|
|
876
|
+
modelName
|
|
877
|
+
);
|
|
878
|
+
if (stringSets) {
|
|
879
|
+
for (const [fieldName, values] of Object.entries(stringSets)) {
|
|
880
|
+
this.sql.exec(
|
|
881
|
+
"DELETE FROM stringset_index WHERE record_id = ? AND type = ? AND field = ?",
|
|
882
|
+
id,
|
|
883
|
+
modelName,
|
|
884
|
+
fieldName
|
|
885
|
+
);
|
|
886
|
+
for (const value of values) {
|
|
887
|
+
this.sql.exec(
|
|
888
|
+
"INSERT OR IGNORE INTO stringset_index (record_id, type, field, value) VALUES (?, ?, ?, ?)",
|
|
889
|
+
id,
|
|
890
|
+
modelName,
|
|
891
|
+
fieldName,
|
|
892
|
+
value
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Atomically increment/decrement numeric fields on a record.
|
|
901
|
+
* Each key in `fields` is a field name and its value is the delta.
|
|
902
|
+
* Missing fields are initialised to 0 before adding the delta.
|
|
903
|
+
* Returns the new values, or null if the record doesn't exist.
|
|
904
|
+
*/
|
|
905
|
+
incrementFields(modelName, id, fields) {
|
|
906
|
+
let result = null;
|
|
907
|
+
this.storage.transactionSync(() => {
|
|
908
|
+
result = this.incrementFieldsRaw(modelName, id, fields);
|
|
909
|
+
});
|
|
910
|
+
return result;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Non-transactional core of incrementFields.
|
|
914
|
+
* Call this from an outer transaction (e.g. batch) to avoid nested transactions.
|
|
915
|
+
* @internal
|
|
916
|
+
*/
|
|
917
|
+
incrementFieldsRaw(modelName, id, fields) {
|
|
918
|
+
const rows = this.execSqlSync(
|
|
919
|
+
"SELECT data_json FROM records WHERE id = ? AND type = ?",
|
|
920
|
+
[id, modelName]
|
|
921
|
+
);
|
|
922
|
+
if (rows.length === 0) return null;
|
|
923
|
+
const data = JSON.parse(rows[0].data_json);
|
|
924
|
+
const newValues = {};
|
|
925
|
+
for (const [field, delta] of Object.entries(fields)) {
|
|
926
|
+
const current = typeof data[field] === "number" ? data[field] : 0;
|
|
927
|
+
data[field] = current + delta;
|
|
928
|
+
newValues[field] = data[field];
|
|
929
|
+
}
|
|
930
|
+
this.sql.exec(
|
|
931
|
+
"UPDATE records SET data_json = ? WHERE id = ? AND type = ?",
|
|
932
|
+
JSON.stringify(data),
|
|
933
|
+
id,
|
|
934
|
+
modelName
|
|
935
|
+
);
|
|
936
|
+
return newValues;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Delete a record and its StringSet values atomically.
|
|
940
|
+
*/
|
|
941
|
+
async deleteWithStringSets(modelName, id) {
|
|
942
|
+
this.storage.transactionSync(() => {
|
|
943
|
+
this.sql.exec("DELETE FROM records WHERE type = ? AND id = ?", modelName, id);
|
|
944
|
+
this.sql.exec(
|
|
945
|
+
"DELETE FROM stringset_index WHERE type = ? AND record_id = ?",
|
|
946
|
+
modelName,
|
|
947
|
+
id
|
|
948
|
+
);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Track field names and inferred types for a model based on written data.
|
|
953
|
+
* Uses INSERT OR IGNORE so the first-seen type wins.
|
|
954
|
+
*/
|
|
955
|
+
trackModelFields(modelName, data) {
|
|
956
|
+
for (const [key, value] of Object.entries(data)) {
|
|
957
|
+
if (key === "id" || key === "type" || value === null || value === void 0) {
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
let inferredType;
|
|
961
|
+
if (typeof value === "number") {
|
|
962
|
+
inferredType = "number";
|
|
963
|
+
} else if (typeof value === "boolean") {
|
|
964
|
+
inferredType = "boolean";
|
|
965
|
+
} else if (Array.isArray(value)) {
|
|
966
|
+
inferredType = "array";
|
|
967
|
+
} else if (typeof value === "object") {
|
|
968
|
+
inferredType = "object";
|
|
969
|
+
} else {
|
|
970
|
+
inferredType = "string";
|
|
971
|
+
}
|
|
972
|
+
this.sql.exec(
|
|
973
|
+
"INSERT OR IGNORE INTO _model_fields (model_name, field_name, inferred_type) VALUES (?, ?, ?)",
|
|
974
|
+
modelName,
|
|
975
|
+
key,
|
|
976
|
+
inferredType
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Get tracked fields for a model, or all models if no name given.
|
|
982
|
+
*/
|
|
983
|
+
getModelFields(modelName) {
|
|
984
|
+
if (modelName) {
|
|
985
|
+
return this.execSqlSync(
|
|
986
|
+
"SELECT model_name, field_name, inferred_type, first_seen_at FROM _model_fields WHERE model_name = ? ORDER BY field_name",
|
|
987
|
+
[modelName]
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return this.execSqlSync(
|
|
991
|
+
"SELECT model_name, field_name, inferred_type, first_seen_at FROM _model_fields ORDER BY model_name, field_name"
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// src/types/queryTypes.ts
|
|
997
|
+
var DocumentQueryError = class _DocumentQueryError extends Error {
|
|
998
|
+
constructor(message) {
|
|
999
|
+
super(message);
|
|
1000
|
+
this.name = "DocumentQueryError";
|
|
1001
|
+
Object.setPrototypeOf(this, _DocumentQueryError.prototype);
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
var InvalidOperatorError = class _InvalidOperatorError extends DocumentQueryError {
|
|
1005
|
+
field;
|
|
1006
|
+
operator;
|
|
1007
|
+
fieldType;
|
|
1008
|
+
constructor(message, field, operator, fieldType) {
|
|
1009
|
+
super(message);
|
|
1010
|
+
this.name = "InvalidOperatorError";
|
|
1011
|
+
this.field = field;
|
|
1012
|
+
this.operator = operator;
|
|
1013
|
+
this.fieldType = fieldType;
|
|
1014
|
+
Object.setPrototypeOf(this, _InvalidOperatorError.prototype);
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
var InvalidFieldError = class _InvalidFieldError extends DocumentQueryError {
|
|
1018
|
+
field;
|
|
1019
|
+
modelName;
|
|
1020
|
+
constructor(message, field, modelName) {
|
|
1021
|
+
super(message);
|
|
1022
|
+
this.name = "InvalidFieldError";
|
|
1023
|
+
this.field = field;
|
|
1024
|
+
this.modelName = modelName;
|
|
1025
|
+
Object.setPrototypeOf(this, _InvalidFieldError.prototype);
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
var InvalidCursorError = class _InvalidCursorError extends DocumentQueryError {
|
|
1029
|
+
cursor;
|
|
1030
|
+
constructor(message, cursor) {
|
|
1031
|
+
super(message);
|
|
1032
|
+
this.name = "InvalidCursorError";
|
|
1033
|
+
this.cursor = cursor;
|
|
1034
|
+
Object.setPrototypeOf(this, _InvalidCursorError.prototype);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// src/query/CursorManager.ts
|
|
1039
|
+
var base64Encode = (str) => {
|
|
1040
|
+
if (typeof btoa !== "undefined") {
|
|
1041
|
+
return btoa(str);
|
|
1042
|
+
} else if (typeof Buffer !== "undefined") {
|
|
1043
|
+
return Buffer.from(str, "utf-8").toString("base64");
|
|
1044
|
+
} else {
|
|
1045
|
+
throw new Error("No base64 encoding available");
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
var base64Decode = (str) => {
|
|
1049
|
+
if (typeof atob !== "undefined") {
|
|
1050
|
+
return atob(str);
|
|
1051
|
+
} else if (typeof Buffer !== "undefined") {
|
|
1052
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
1053
|
+
} else {
|
|
1054
|
+
throw new Error("No base64 decoding available");
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
var CursorManager = class {
|
|
1058
|
+
/**
|
|
1059
|
+
* Encode cursor data to base64 string
|
|
1060
|
+
*/
|
|
1061
|
+
static encodeCursor(cursorData) {
|
|
1062
|
+
try {
|
|
1063
|
+
const jsonString = JSON.stringify(cursorData);
|
|
1064
|
+
return base64Encode(jsonString);
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
throw new InvalidCursorError(
|
|
1067
|
+
`Failed to encode cursor: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1068
|
+
JSON.stringify(cursorData)
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Decode base64 cursor string to cursor data
|
|
1074
|
+
*/
|
|
1075
|
+
static decodeCursor(cursor) {
|
|
1076
|
+
try {
|
|
1077
|
+
const jsonString = base64Decode(cursor);
|
|
1078
|
+
const parsed = JSON.parse(jsonString);
|
|
1079
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1080
|
+
throw new Error("Cursor must be an object");
|
|
1081
|
+
}
|
|
1082
|
+
if (!parsed.values || typeof parsed.values !== "object") {
|
|
1083
|
+
throw new Error("Cursor must have values object");
|
|
1084
|
+
}
|
|
1085
|
+
if (!Array.isArray(parsed.sortFields)) {
|
|
1086
|
+
throw new Error("Cursor must have sortFields array");
|
|
1087
|
+
}
|
|
1088
|
+
if (parsed.direction !== 1 && parsed.direction !== -1) {
|
|
1089
|
+
throw new Error("Cursor direction must be 1 or -1");
|
|
1090
|
+
}
|
|
1091
|
+
return parsed;
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
throw new InvalidCursorError(
|
|
1094
|
+
`Failed to decode cursor: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1095
|
+
cursor
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Generate cursor from a record and sort specification
|
|
1101
|
+
*/
|
|
1102
|
+
static generateCursor(record, sortFields, direction) {
|
|
1103
|
+
const values = {};
|
|
1104
|
+
for (const field of sortFields) {
|
|
1105
|
+
if (record.hasOwnProperty(field)) {
|
|
1106
|
+
values[field] = record[field];
|
|
1107
|
+
} else {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`Cannot generate cursor: record missing sort field '${field}'`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const cursorData = {
|
|
1114
|
+
values,
|
|
1115
|
+
sortFields,
|
|
1116
|
+
direction
|
|
1117
|
+
};
|
|
1118
|
+
return this.encodeCursor(cursorData);
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Build SQL pagination conditions based on cursor
|
|
1122
|
+
* Uses lexicographic ordering for stable pagination with multiple sort fields
|
|
1123
|
+
*/
|
|
1124
|
+
static buildPaginationConditions(cursor, currentSortFields, sortDirections, requestedDirection, fieldFormatter = (field) => field) {
|
|
1125
|
+
if (!this.arraysEqual(cursor.sortFields, currentSortFields)) {
|
|
1126
|
+
throw new InvalidCursorError(
|
|
1127
|
+
`Cursor sort fields [${cursor.sortFields.join(
|
|
1128
|
+
", "
|
|
1129
|
+
)}] don't match query sort fields [${currentSortFields.join(", ")}]`,
|
|
1130
|
+
JSON.stringify(cursor)
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
const conditions = [];
|
|
1134
|
+
const params = [];
|
|
1135
|
+
const sortFields = cursor.sortFields;
|
|
1136
|
+
const direction = requestedDirection;
|
|
1137
|
+
for (let i = 0; i < sortFields.length; i++) {
|
|
1138
|
+
const fieldConditions = [];
|
|
1139
|
+
const fieldParams = [];
|
|
1140
|
+
for (let j = 0; j < i; j++) {
|
|
1141
|
+
const field = sortFields[j];
|
|
1142
|
+
const value = cursor.values[field];
|
|
1143
|
+
fieldConditions.push(`${fieldFormatter(field)} = ?`);
|
|
1144
|
+
fieldParams.push(value);
|
|
1145
|
+
}
|
|
1146
|
+
const currentField = sortFields[i];
|
|
1147
|
+
const currentValue = cursor.values[currentField];
|
|
1148
|
+
const fieldSortDir = sortDirections[i] ?? 1;
|
|
1149
|
+
const forwardOp = fieldSortDir === 1 ? ">" : "<";
|
|
1150
|
+
const operator = direction === 1 ? forwardOp : forwardOp === ">" ? "<" : ">";
|
|
1151
|
+
fieldConditions.push(`${fieldFormatter(currentField)} ${operator} ?`);
|
|
1152
|
+
fieldParams.push(currentValue);
|
|
1153
|
+
const levelCondition = fieldConditions.join(" AND ");
|
|
1154
|
+
conditions.push(`(${levelCondition})`);
|
|
1155
|
+
params.push(...fieldParams);
|
|
1156
|
+
}
|
|
1157
|
+
const sql = `(${conditions.join(" OR ")})`;
|
|
1158
|
+
return { sql, params };
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Extract sort fields from sort specification, ensuring 'id' is always included for stability
|
|
1162
|
+
*/
|
|
1163
|
+
static extractSortFields(sort) {
|
|
1164
|
+
const fields = [];
|
|
1165
|
+
if (sort && Object.keys(sort).length > 0) {
|
|
1166
|
+
fields.push(...Object.keys(sort));
|
|
1167
|
+
}
|
|
1168
|
+
if (!fields.includes("id")) {
|
|
1169
|
+
fields.push("id");
|
|
1170
|
+
}
|
|
1171
|
+
return fields;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Build ORDER BY clause from sort specification
|
|
1175
|
+
*/
|
|
1176
|
+
static buildOrderClause(sort, fieldFormatter = (field) => field) {
|
|
1177
|
+
const fields = this.extractSortFields(sort);
|
|
1178
|
+
const clauses = [];
|
|
1179
|
+
const directions = [];
|
|
1180
|
+
if (sort && Object.keys(sort).length > 0) {
|
|
1181
|
+
for (const [field, dir] of Object.entries(sort)) {
|
|
1182
|
+
const sqlDirection = dir === 1 ? "ASC" : "DESC";
|
|
1183
|
+
clauses.push(`${fieldFormatter(field)} ${sqlDirection}`);
|
|
1184
|
+
directions.push(dir);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (!sort || !sort.hasOwnProperty("id")) {
|
|
1188
|
+
clauses.push(`${fieldFormatter("id")} ASC`);
|
|
1189
|
+
directions.push(1);
|
|
1190
|
+
} else if (sort && sort.hasOwnProperty("id")) {
|
|
1191
|
+
directions.push(sort["id"]);
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
sql: clauses.join(", "),
|
|
1195
|
+
fields,
|
|
1196
|
+
directions
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Determine if there are more results available for pagination
|
|
1201
|
+
*/
|
|
1202
|
+
static hasMoreResults(requestedLimit, actualResultCount) {
|
|
1203
|
+
if (!requestedLimit || requestedLimit <= 0) {
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
return actualResultCount >= requestedLimit;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Generate next and previous cursors from result set
|
|
1210
|
+
*/
|
|
1211
|
+
static generateResultCursors(results, sortFields, _requestedDirection, hasMore, isFirstPage = false) {
|
|
1212
|
+
if (results.length === 0) {
|
|
1213
|
+
return {};
|
|
1214
|
+
}
|
|
1215
|
+
const cursors = {};
|
|
1216
|
+
if (hasMore && results.length > 0) {
|
|
1217
|
+
const lastResult = results[results.length - 1];
|
|
1218
|
+
cursors.nextCursor = this.generateCursor(lastResult, sortFields, 1);
|
|
1219
|
+
}
|
|
1220
|
+
if (results.length > 0 && !isFirstPage) {
|
|
1221
|
+
const firstResult = results[0];
|
|
1222
|
+
cursors.prevCursor = this.generateCursor(firstResult, sortFields, -1);
|
|
1223
|
+
}
|
|
1224
|
+
return cursors;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Utility to compare arrays for equality
|
|
1228
|
+
*/
|
|
1229
|
+
static arraysEqual(a, b) {
|
|
1230
|
+
if (a.length !== b.length) return false;
|
|
1231
|
+
return a.every((val, index) => val === b[index]);
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
// src/utils/patterns.ts
|
|
1236
|
+
function escapeLikeLiteral(input) {
|
|
1237
|
+
return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
1238
|
+
}
|
|
1239
|
+
function buildLikePattern(value, mode) {
|
|
1240
|
+
const trimmed = (value ?? "").trim();
|
|
1241
|
+
if (trimmed.length === 0) return null;
|
|
1242
|
+
if (trimmed.length > 1024) {
|
|
1243
|
+
throw new Error("substring value exceeds 1024 characters");
|
|
1244
|
+
}
|
|
1245
|
+
const escaped = escapeLikeLiteral(trimmed);
|
|
1246
|
+
switch (mode) {
|
|
1247
|
+
case "startsWith":
|
|
1248
|
+
return `${escaped}%`;
|
|
1249
|
+
case "endsWith":
|
|
1250
|
+
return `%${escaped}`;
|
|
1251
|
+
case "containsText":
|
|
1252
|
+
return `%${escaped}%`;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
function shouldLogLikeEscapes() {
|
|
1256
|
+
try {
|
|
1257
|
+
if (typeof process !== "undefined" && process.env) {
|
|
1258
|
+
return !!process.env.JS_BAO_DEBUG_LIKE_ESCAPES;
|
|
1259
|
+
}
|
|
1260
|
+
} catch {
|
|
1261
|
+
}
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/query/JsonQueryTranslator.ts
|
|
1266
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["id", "type"]);
|
|
1267
|
+
var JsonQueryTranslator = class {
|
|
1268
|
+
modelName;
|
|
1269
|
+
schema;
|
|
1270
|
+
options;
|
|
1271
|
+
fieldSqlCache;
|
|
1272
|
+
constructor(modelName, schema, options = { includeDocId: false }) {
|
|
1273
|
+
this.modelName = modelName;
|
|
1274
|
+
this.schema = schema;
|
|
1275
|
+
this.options = options;
|
|
1276
|
+
this.fieldSqlCache = /* @__PURE__ */ new Map();
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Get SQL expression for a field.
|
|
1280
|
+
* System fields (id, type) are direct columns; others use json_extract.
|
|
1281
|
+
* When no schema is provided, any field is accepted (schemaless mode).
|
|
1282
|
+
*/
|
|
1283
|
+
getFieldSql(fieldName) {
|
|
1284
|
+
if (!this.fieldSqlCache.has(fieldName)) {
|
|
1285
|
+
if (SYSTEM_FIELDS.has(fieldName)) {
|
|
1286
|
+
this.fieldSqlCache.set(fieldName, quoteIdentifier(fieldName));
|
|
1287
|
+
} else {
|
|
1288
|
+
assertValidIdentifier(fieldName, "query field");
|
|
1289
|
+
this.fieldSqlCache.set(
|
|
1290
|
+
fieldName,
|
|
1291
|
+
`json_extract(data_json, '$.${fieldName}')`
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return this.fieldSqlCache.get(fieldName);
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Translate document filter and options to SQL for find operations
|
|
1299
|
+
*/
|
|
1300
|
+
translateFind(filter, options) {
|
|
1301
|
+
if (options?.projection) {
|
|
1302
|
+
this.validateProjection(options.projection);
|
|
1303
|
+
}
|
|
1304
|
+
const whereClause = this.translateFilter(filter);
|
|
1305
|
+
const orderClause = CursorManager.buildOrderClause(
|
|
1306
|
+
options?.sort,
|
|
1307
|
+
(field) => this.getFieldSql(field)
|
|
1308
|
+
);
|
|
1309
|
+
const limitClause = this.buildLimitClause(options);
|
|
1310
|
+
const paginationClause = this.buildPaginationClause(
|
|
1311
|
+
options,
|
|
1312
|
+
orderClause.fields,
|
|
1313
|
+
orderClause.directions
|
|
1314
|
+
);
|
|
1315
|
+
const selectClause = this.buildSelectClause(options?.projection);
|
|
1316
|
+
let sql = `SELECT ${selectClause} FROM records`;
|
|
1317
|
+
const params = [];
|
|
1318
|
+
const conditions = [`${quoteIdentifier("type")} = ?`];
|
|
1319
|
+
params.push(this.modelName);
|
|
1320
|
+
if (whereClause.sql) {
|
|
1321
|
+
conditions.push(whereClause.sql);
|
|
1322
|
+
params.push(...whereClause.params);
|
|
1323
|
+
}
|
|
1324
|
+
if (paginationClause.sql) {
|
|
1325
|
+
conditions.push(paginationClause.sql);
|
|
1326
|
+
params.push(...paginationClause.params);
|
|
1327
|
+
}
|
|
1328
|
+
if (this.options.includeDocId) {
|
|
1329
|
+
const documentClause = this.buildDocumentClause(options?.documents);
|
|
1330
|
+
if (documentClause) {
|
|
1331
|
+
conditions.push(documentClause.sql);
|
|
1332
|
+
params.push(...documentClause.params);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
1336
|
+
if (orderClause.sql) {
|
|
1337
|
+
sql += ` ORDER BY ${orderClause.sql}`;
|
|
1338
|
+
}
|
|
1339
|
+
if (limitClause.sql) {
|
|
1340
|
+
sql += ` LIMIT ${limitClause.sql}`;
|
|
1341
|
+
}
|
|
1342
|
+
return {
|
|
1343
|
+
sql,
|
|
1344
|
+
params,
|
|
1345
|
+
sortFields: orderClause.fields,
|
|
1346
|
+
sortDirections: orderClause.directions
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Translate document filter to SQL for count operations
|
|
1351
|
+
*/
|
|
1352
|
+
translateCount(filter, options) {
|
|
1353
|
+
const whereClause = this.translateFilter(filter);
|
|
1354
|
+
const conditions = [`${quoteIdentifier("type")} = ?`];
|
|
1355
|
+
const params = [this.modelName];
|
|
1356
|
+
if (whereClause.sql) {
|
|
1357
|
+
conditions.push(whereClause.sql);
|
|
1358
|
+
params.push(...whereClause.params);
|
|
1359
|
+
}
|
|
1360
|
+
if (this.options.includeDocId) {
|
|
1361
|
+
const documentClause = this.buildDocumentClause(options?.documents);
|
|
1362
|
+
if (documentClause) {
|
|
1363
|
+
conditions.push(documentClause.sql);
|
|
1364
|
+
params.push(...documentClause.params);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const sql = `SELECT COUNT(*) as count FROM records WHERE ${conditions.join(
|
|
1368
|
+
" AND "
|
|
1369
|
+
)}`;
|
|
1370
|
+
return { sql, params };
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Translate document filter to SQL WHERE clause.
|
|
1374
|
+
* Returns the conditions and params without the type = ? prefix.
|
|
1375
|
+
*/
|
|
1376
|
+
translateFilter(filter) {
|
|
1377
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
1378
|
+
return { sql: "", params: [] };
|
|
1379
|
+
}
|
|
1380
|
+
const conditions = [];
|
|
1381
|
+
const params = [];
|
|
1382
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1383
|
+
if (key === "$and") {
|
|
1384
|
+
const andClause = this.translateLogicalOperator(
|
|
1385
|
+
"AND",
|
|
1386
|
+
value
|
|
1387
|
+
);
|
|
1388
|
+
if (andClause.sql) {
|
|
1389
|
+
conditions.push(`(${andClause.sql})`);
|
|
1390
|
+
params.push(...andClause.params);
|
|
1391
|
+
}
|
|
1392
|
+
} else if (key === "$or") {
|
|
1393
|
+
const orClause = this.translateLogicalOperator(
|
|
1394
|
+
"OR",
|
|
1395
|
+
value
|
|
1396
|
+
);
|
|
1397
|
+
if (orClause.sql) {
|
|
1398
|
+
conditions.push(`(${orClause.sql})`);
|
|
1399
|
+
params.push(...orClause.params);
|
|
1400
|
+
}
|
|
1401
|
+
} else {
|
|
1402
|
+
const fieldClause = this.translateFieldCondition(key, value);
|
|
1403
|
+
if (fieldClause.sql) {
|
|
1404
|
+
conditions.push(fieldClause.sql);
|
|
1405
|
+
params.push(...fieldClause.params);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return {
|
|
1410
|
+
sql: conditions.join(" AND "),
|
|
1411
|
+
params
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Translate logical operators ($and, $or)
|
|
1416
|
+
*/
|
|
1417
|
+
translateLogicalOperator(operator, filters) {
|
|
1418
|
+
if (!Array.isArray(filters) || filters.length === 0) {
|
|
1419
|
+
return { sql: "", params: [] };
|
|
1420
|
+
}
|
|
1421
|
+
const clauses = [];
|
|
1422
|
+
const params = [];
|
|
1423
|
+
for (const filter of filters) {
|
|
1424
|
+
const clause = this.translateFilter(filter);
|
|
1425
|
+
if (clause.sql) {
|
|
1426
|
+
clauses.push(clause.sql);
|
|
1427
|
+
params.push(...clause.params);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (clauses.length === 0) {
|
|
1431
|
+
return { sql: "", params: [] };
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
1434
|
+
sql: clauses.join(` ${operator} `),
|
|
1435
|
+
params
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Translate field condition to SQL
|
|
1440
|
+
*/
|
|
1441
|
+
translateFieldCondition(fieldName, condition) {
|
|
1442
|
+
const fieldSql = this.getFieldSql(fieldName);
|
|
1443
|
+
if (condition === null || condition === void 0) {
|
|
1444
|
+
return { sql: `${fieldSql} IS NULL`, params: [] };
|
|
1445
|
+
}
|
|
1446
|
+
if (this.isPrimitiveValue(condition)) {
|
|
1447
|
+
this.validateFieldValue(fieldName, condition);
|
|
1448
|
+
return {
|
|
1449
|
+
sql: `${fieldSql} = ?`,
|
|
1450
|
+
params: [this.convertValueForSQLite(condition)]
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
if (typeof condition === "object" && !Array.isArray(condition)) {
|
|
1454
|
+
return this.translateFieldOperators(fieldName, condition);
|
|
1455
|
+
}
|
|
1456
|
+
if (Array.isArray(condition)) {
|
|
1457
|
+
this.validateArrayValues(fieldName, condition);
|
|
1458
|
+
const placeholders = condition.map(() => "?").join(",");
|
|
1459
|
+
const convertedValues = condition.map(
|
|
1460
|
+
(v) => this.convertValueForSQLite(v)
|
|
1461
|
+
);
|
|
1462
|
+
return {
|
|
1463
|
+
sql: `${fieldSql} IN (${placeholders})`,
|
|
1464
|
+
params: convertedValues
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
throw new InvalidOperatorError(
|
|
1468
|
+
`Unsupported condition type for field ${fieldName}`,
|
|
1469
|
+
fieldName,
|
|
1470
|
+
"unknown"
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Translate field operators to SQL
|
|
1475
|
+
*/
|
|
1476
|
+
translateFieldOperators(fieldName, operators) {
|
|
1477
|
+
const conditions = [];
|
|
1478
|
+
const params = [];
|
|
1479
|
+
const fieldOptions = this.schema?.get(fieldName);
|
|
1480
|
+
const fieldType = fieldOptions?.type;
|
|
1481
|
+
const fieldSql = this.getFieldSql(fieldName);
|
|
1482
|
+
const substringOps = [
|
|
1483
|
+
"$startsWith",
|
|
1484
|
+
"$endsWith",
|
|
1485
|
+
"$containsText"
|
|
1486
|
+
];
|
|
1487
|
+
const presentSubstringOps = substringOps.filter(
|
|
1488
|
+
(op) => Object.prototype.hasOwnProperty.call(operators, op)
|
|
1489
|
+
);
|
|
1490
|
+
if (presentSubstringOps.length > 1) {
|
|
1491
|
+
throw new InvalidOperatorError(
|
|
1492
|
+
`Only one of $startsWith, $endsWith, $containsText may be used per field`,
|
|
1493
|
+
fieldName,
|
|
1494
|
+
presentSubstringOps[1],
|
|
1495
|
+
fieldType
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
if (presentSubstringOps.length === 1) {
|
|
1499
|
+
const op = presentSubstringOps[0];
|
|
1500
|
+
const value = operators[op];
|
|
1501
|
+
if (fieldType && fieldType !== "string" && fieldType !== "stringset") {
|
|
1502
|
+
throw new InvalidOperatorError(
|
|
1503
|
+
`${op} operator is only supported for string and stringset fields, field ${fieldName} is type ${fieldType}`,
|
|
1504
|
+
fieldName,
|
|
1505
|
+
op,
|
|
1506
|
+
fieldType
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (typeof value !== "string") {
|
|
1510
|
+
throw new InvalidOperatorError(
|
|
1511
|
+
`${op} operator requires a string value for field ${fieldName}`,
|
|
1512
|
+
fieldName,
|
|
1513
|
+
op,
|
|
1514
|
+
fieldType
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
let pattern;
|
|
1518
|
+
try {
|
|
1519
|
+
const mode = op === "$startsWith" ? "startsWith" : op === "$endsWith" ? "endsWith" : "containsText";
|
|
1520
|
+
pattern = buildLikePattern(value, mode);
|
|
1521
|
+
} catch (e) {
|
|
1522
|
+
throw new InvalidOperatorError(
|
|
1523
|
+
e.message || "invalid substring value",
|
|
1524
|
+
fieldName,
|
|
1525
|
+
op,
|
|
1526
|
+
fieldType
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
if (pattern !== null) {
|
|
1530
|
+
if (shouldLogLikeEscapes()) {
|
|
1531
|
+
try {
|
|
1532
|
+
console.debug(
|
|
1533
|
+
`[Substring] field=${fieldName} op=${op} pattern=${pattern} ci=true`
|
|
1534
|
+
);
|
|
1535
|
+
} catch {
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (fieldType === "stringset") {
|
|
1539
|
+
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)`;
|
|
1540
|
+
conditions.push(likeSql);
|
|
1541
|
+
params.push(this.modelName, fieldName, pattern);
|
|
1542
|
+
} else {
|
|
1543
|
+
const likeSql = `${fieldSql} LIKE ? ESCAPE '\\' COLLATE NOCASE`;
|
|
1544
|
+
conditions.push(likeSql);
|
|
1545
|
+
params.push(pattern);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
for (const [operator, value] of Object.entries(operators)) {
|
|
1550
|
+
if (substringOps.includes(operator)) {
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
const opClause = this.translateOperator(fieldName, operator, value);
|
|
1554
|
+
if (opClause.sql) {
|
|
1555
|
+
conditions.push(opClause.sql);
|
|
1556
|
+
params.push(...opClause.params);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
sql: conditions.join(" AND "),
|
|
1561
|
+
params
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Translate individual operator to SQL
|
|
1566
|
+
*/
|
|
1567
|
+
translateOperator(fieldName, operator, value) {
|
|
1568
|
+
const fieldOptions = this.schema?.get(fieldName);
|
|
1569
|
+
const fieldType = fieldOptions?.type;
|
|
1570
|
+
const fieldSql = this.getFieldSql(fieldName);
|
|
1571
|
+
switch (operator) {
|
|
1572
|
+
case "$eq":
|
|
1573
|
+
this.validateFieldValue(fieldName, value);
|
|
1574
|
+
return {
|
|
1575
|
+
sql: `${fieldSql} = ?`,
|
|
1576
|
+
params: [this.convertValueForSQLite(value)]
|
|
1577
|
+
};
|
|
1578
|
+
case "$ne":
|
|
1579
|
+
this.validateFieldValue(fieldName, value);
|
|
1580
|
+
return {
|
|
1581
|
+
sql: `${fieldSql} != ?`,
|
|
1582
|
+
params: [this.convertValueForSQLite(value)]
|
|
1583
|
+
};
|
|
1584
|
+
case "$gt":
|
|
1585
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1586
|
+
"id",
|
|
1587
|
+
"string",
|
|
1588
|
+
"number",
|
|
1589
|
+
"date"
|
|
1590
|
+
]);
|
|
1591
|
+
this.validateFieldValue(fieldName, value);
|
|
1592
|
+
return {
|
|
1593
|
+
sql: `${fieldSql} > ?`,
|
|
1594
|
+
params: [this.convertValueForSQLite(value)]
|
|
1595
|
+
};
|
|
1596
|
+
case "$gte":
|
|
1597
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1598
|
+
"id",
|
|
1599
|
+
"string",
|
|
1600
|
+
"number",
|
|
1601
|
+
"date"
|
|
1602
|
+
]);
|
|
1603
|
+
this.validateFieldValue(fieldName, value);
|
|
1604
|
+
return {
|
|
1605
|
+
sql: `${fieldSql} >= ?`,
|
|
1606
|
+
params: [this.convertValueForSQLite(value)]
|
|
1607
|
+
};
|
|
1608
|
+
case "$lt":
|
|
1609
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1610
|
+
"id",
|
|
1611
|
+
"string",
|
|
1612
|
+
"number",
|
|
1613
|
+
"date"
|
|
1614
|
+
]);
|
|
1615
|
+
this.validateFieldValue(fieldName, value);
|
|
1616
|
+
return {
|
|
1617
|
+
sql: `${fieldSql} < ?`,
|
|
1618
|
+
params: [this.convertValueForSQLite(value)]
|
|
1619
|
+
};
|
|
1620
|
+
case "$lte":
|
|
1621
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
1622
|
+
"id",
|
|
1623
|
+
"string",
|
|
1624
|
+
"number",
|
|
1625
|
+
"date"
|
|
1626
|
+
]);
|
|
1627
|
+
this.validateFieldValue(fieldName, value);
|
|
1628
|
+
return {
|
|
1629
|
+
sql: `${fieldSql} <= ?`,
|
|
1630
|
+
params: [this.convertValueForSQLite(value)]
|
|
1631
|
+
};
|
|
1632
|
+
case "$in":
|
|
1633
|
+
if (!Array.isArray(value)) {
|
|
1634
|
+
throw new InvalidOperatorError(
|
|
1635
|
+
`$in operator requires an array value for field ${fieldName}`,
|
|
1636
|
+
fieldName,
|
|
1637
|
+
operator,
|
|
1638
|
+
fieldType
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
this.validateArrayValues(fieldName, value);
|
|
1642
|
+
if (value.length === 0) {
|
|
1643
|
+
return { sql: "1 = 0", params: [] };
|
|
1644
|
+
}
|
|
1645
|
+
const placeholders = value.map(() => "?").join(",");
|
|
1646
|
+
const convertedValues = value.map((v) => this.convertValueForSQLite(v));
|
|
1647
|
+
return {
|
|
1648
|
+
sql: `${fieldSql} IN (${placeholders})`,
|
|
1649
|
+
params: convertedValues
|
|
1650
|
+
};
|
|
1651
|
+
case "$nin":
|
|
1652
|
+
if (!Array.isArray(value)) {
|
|
1653
|
+
throw new InvalidOperatorError(
|
|
1654
|
+
`$nin operator requires an array value for field ${fieldName}`,
|
|
1655
|
+
fieldName,
|
|
1656
|
+
operator,
|
|
1657
|
+
fieldType
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
this.validateArrayValues(fieldName, value);
|
|
1661
|
+
if (value.length === 0) {
|
|
1662
|
+
return { sql: "", params: [] };
|
|
1663
|
+
}
|
|
1664
|
+
const ninPlaceholders = value.map(() => "?").join(",");
|
|
1665
|
+
const convertedNinValues = value.map(
|
|
1666
|
+
(v) => this.convertValueForSQLite(v)
|
|
1667
|
+
);
|
|
1668
|
+
return {
|
|
1669
|
+
sql: `${fieldSql} NOT IN (${ninPlaceholders})`,
|
|
1670
|
+
params: convertedNinValues
|
|
1671
|
+
};
|
|
1672
|
+
case "$exists":
|
|
1673
|
+
if (typeof value !== "boolean") {
|
|
1674
|
+
throw new InvalidOperatorError(
|
|
1675
|
+
`$exists operator requires a boolean value for field ${fieldName}`,
|
|
1676
|
+
fieldName,
|
|
1677
|
+
operator,
|
|
1678
|
+
fieldType
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
if (SYSTEM_FIELDS.has(fieldName)) {
|
|
1682
|
+
return value ? { sql: `${fieldSql} IS NOT NULL`, params: [] } : { sql: `${fieldSql} IS NULL`, params: [] };
|
|
1683
|
+
} else {
|
|
1684
|
+
return value ? {
|
|
1685
|
+
sql: `json_type(data_json, '$.${fieldName}') IS NOT NULL`,
|
|
1686
|
+
params: []
|
|
1687
|
+
} : {
|
|
1688
|
+
sql: `json_type(data_json, '$.${fieldName}') IS NULL`,
|
|
1689
|
+
params: []
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
case "$contains":
|
|
1693
|
+
if (fieldType && fieldType !== "stringset") {
|
|
1694
|
+
throw new InvalidOperatorError(
|
|
1695
|
+
`$contains operator is only supported for stringset fields, field ${fieldName} is type ${fieldType}`,
|
|
1696
|
+
fieldName,
|
|
1697
|
+
operator,
|
|
1698
|
+
fieldType
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
if (typeof value !== "string") {
|
|
1702
|
+
throw new InvalidOperatorError(
|
|
1703
|
+
`$contains operator requires a string value for field ${fieldName}`,
|
|
1704
|
+
fieldName,
|
|
1705
|
+
operator,
|
|
1706
|
+
fieldType
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
return {
|
|
1710
|
+
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 = ?)`,
|
|
1711
|
+
params: [this.modelName, fieldName, value]
|
|
1712
|
+
};
|
|
1713
|
+
default:
|
|
1714
|
+
throw new InvalidOperatorError(
|
|
1715
|
+
`Unsupported operator: ${operator} for field ${fieldName}`,
|
|
1716
|
+
fieldName,
|
|
1717
|
+
operator,
|
|
1718
|
+
fieldType
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Build SELECT clause based on projection
|
|
1724
|
+
* For JSON schema, we need to extract fields from data_json
|
|
1725
|
+
*/
|
|
1726
|
+
buildSelectClause(projection) {
|
|
1727
|
+
if (!projection || Object.keys(projection).length === 0) {
|
|
1728
|
+
return "id, type, data_json";
|
|
1729
|
+
}
|
|
1730
|
+
const includeFields = [];
|
|
1731
|
+
const excludeFields = [];
|
|
1732
|
+
let hasIncludes = false;
|
|
1733
|
+
let hasExcludes = false;
|
|
1734
|
+
for (const [field, include] of Object.entries(projection)) {
|
|
1735
|
+
if (include === 1) {
|
|
1736
|
+
includeFields.push(field);
|
|
1737
|
+
hasIncludes = true;
|
|
1738
|
+
} else if (include === 0) {
|
|
1739
|
+
excludeFields.push(field);
|
|
1740
|
+
hasExcludes = true;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
if (hasIncludes && hasExcludes) {
|
|
1744
|
+
throw new InvalidOperatorError(
|
|
1745
|
+
"Cannot mix inclusion and exclusion in projection",
|
|
1746
|
+
"projection",
|
|
1747
|
+
"mixed"
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
if (hasIncludes) {
|
|
1751
|
+
if (!includeFields.includes("id")) {
|
|
1752
|
+
includeFields.unshift("id");
|
|
1753
|
+
}
|
|
1754
|
+
const selectParts = ["id", "type"];
|
|
1755
|
+
for (const field of includeFields) {
|
|
1756
|
+
if (field === "id" || field === "type") continue;
|
|
1757
|
+
selectParts.push(
|
|
1758
|
+
`json_extract(data_json, '$.${field}') AS ${quoteIdentifier(field)}`
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
return selectParts.join(", ");
|
|
1762
|
+
} else if (hasExcludes) {
|
|
1763
|
+
return "id, type, data_json";
|
|
1764
|
+
}
|
|
1765
|
+
return "id, type, data_json";
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Build LIMIT clause
|
|
1769
|
+
*/
|
|
1770
|
+
buildLimitClause(options) {
|
|
1771
|
+
if (options?.limit && options.limit > 0) {
|
|
1772
|
+
return { sql: options.limit.toString() };
|
|
1773
|
+
}
|
|
1774
|
+
return { sql: "" };
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Build pagination WHERE clause from cursor
|
|
1778
|
+
*/
|
|
1779
|
+
buildPaginationClause(options, sortFields, sortDirections) {
|
|
1780
|
+
if (!options?.uniqueStartKey || !sortFields || !sortDirections) {
|
|
1781
|
+
return { sql: "", params: [] };
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
const cursor = CursorManager.decodeCursor(options.uniqueStartKey);
|
|
1785
|
+
const direction = options.direction || 1;
|
|
1786
|
+
return CursorManager.buildPaginationConditions(
|
|
1787
|
+
cursor,
|
|
1788
|
+
sortFields,
|
|
1789
|
+
sortDirections,
|
|
1790
|
+
direction,
|
|
1791
|
+
(field) => this.getFieldSql(field)
|
|
1792
|
+
);
|
|
1793
|
+
} catch (error) {
|
|
1794
|
+
console.warn("Invalid cursor provided, ignoring pagination:", error);
|
|
1795
|
+
return { sql: "", params: [] };
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Validate projection fields — skipped in schemaless mode
|
|
1800
|
+
*/
|
|
1801
|
+
validateProjection(projection) {
|
|
1802
|
+
if (!this.schema) return;
|
|
1803
|
+
for (const fieldName of Object.keys(projection)) {
|
|
1804
|
+
if (!this.schema.has(fieldName) && !SYSTEM_FIELDS.has(fieldName)) {
|
|
1805
|
+
throw new InvalidFieldError(
|
|
1806
|
+
`Unknown field: ${fieldName} in model ${this.modelName}`,
|
|
1807
|
+
fieldName,
|
|
1808
|
+
this.modelName
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Validate operator is supported for field type
|
|
1815
|
+
*/
|
|
1816
|
+
validateOperatorForType(operator, fieldType, allowedTypes) {
|
|
1817
|
+
if (!fieldType) {
|
|
1818
|
+
console.warn(`Field type not specified, allowing operator ${operator}`);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (!allowedTypes.includes(fieldType)) {
|
|
1822
|
+
throw new InvalidOperatorError(
|
|
1823
|
+
`Operator ${operator} is not supported for field type ${fieldType}. Allowed types: ${allowedTypes.join(
|
|
1824
|
+
", "
|
|
1825
|
+
)}`,
|
|
1826
|
+
"unknown",
|
|
1827
|
+
operator,
|
|
1828
|
+
fieldType
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Validate field value matches expected type
|
|
1834
|
+
*/
|
|
1835
|
+
validateFieldValue(fieldName, value) {
|
|
1836
|
+
if (!this.schema) return;
|
|
1837
|
+
const fieldOptions = this.schema.get(fieldName);
|
|
1838
|
+
if (!fieldOptions || !fieldOptions.type) {
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
const expectedType = fieldOptions.type;
|
|
1842
|
+
const actualType = this.getValueType(value);
|
|
1843
|
+
if (!this.isTypeCompatible(expectedType, actualType, value)) {
|
|
1844
|
+
throw new InvalidOperatorError(
|
|
1845
|
+
`Field ${fieldName} expects ${expectedType}, got ${actualType}`,
|
|
1846
|
+
fieldName,
|
|
1847
|
+
"type_mismatch",
|
|
1848
|
+
expectedType
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Validate array values for $in/$nin operators
|
|
1854
|
+
*/
|
|
1855
|
+
validateArrayValues(fieldName, values) {
|
|
1856
|
+
for (const value of values) {
|
|
1857
|
+
this.validateFieldValue(fieldName, value);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Check if value is a primitive (not an object with operators)
|
|
1862
|
+
*/
|
|
1863
|
+
isPrimitiveValue(value) {
|
|
1864
|
+
if (value === null || value === void 0) {
|
|
1865
|
+
return true;
|
|
1866
|
+
}
|
|
1867
|
+
const type = typeof value;
|
|
1868
|
+
return type === "string" || type === "number" || type === "boolean" || value instanceof Date;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Get the type of a value for validation
|
|
1872
|
+
*/
|
|
1873
|
+
getValueType(value) {
|
|
1874
|
+
if (value === null || value === void 0) {
|
|
1875
|
+
return "null";
|
|
1876
|
+
}
|
|
1877
|
+
if (value instanceof Date) {
|
|
1878
|
+
return "date";
|
|
1879
|
+
}
|
|
1880
|
+
return typeof value;
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Check if value type is compatible with field type
|
|
1884
|
+
*/
|
|
1885
|
+
isTypeCompatible(expectedType, actualType, value) {
|
|
1886
|
+
if (actualType === "null") {
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
1889
|
+
switch (expectedType) {
|
|
1890
|
+
case "id":
|
|
1891
|
+
return actualType === "string";
|
|
1892
|
+
// id fields are strings
|
|
1893
|
+
case "string":
|
|
1894
|
+
return actualType === "string";
|
|
1895
|
+
case "number":
|
|
1896
|
+
return actualType === "number" && !isNaN(value);
|
|
1897
|
+
case "boolean":
|
|
1898
|
+
return actualType === "boolean";
|
|
1899
|
+
case "date":
|
|
1900
|
+
return actualType === "date" || actualType === "string" && !isNaN(Date.parse(value));
|
|
1901
|
+
default:
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Convert value for SQLite compatibility
|
|
1907
|
+
*/
|
|
1908
|
+
convertValueForSQLite(value) {
|
|
1909
|
+
if (typeof value === "boolean") {
|
|
1910
|
+
return value ? 1 : 0;
|
|
1911
|
+
}
|
|
1912
|
+
return value;
|
|
1913
|
+
}
|
|
1914
|
+
normalizeDocumentIds(documents) {
|
|
1915
|
+
if (documents == null) {
|
|
1916
|
+
return null;
|
|
1917
|
+
}
|
|
1918
|
+
const docArray = Array.isArray(documents) ? documents : [documents];
|
|
1919
|
+
const normalized = docArray.map((doc) => `${doc}`.trim()).filter((doc) => doc.length > 0);
|
|
1920
|
+
if (normalized.length === 0) {
|
|
1921
|
+
return [];
|
|
1922
|
+
}
|
|
1923
|
+
return Array.from(new Set(normalized));
|
|
1924
|
+
}
|
|
1925
|
+
buildDocumentClause(documents) {
|
|
1926
|
+
const normalized = this.normalizeDocumentIds(documents);
|
|
1927
|
+
if (normalized === null) {
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
if (normalized.length === 0) {
|
|
1931
|
+
return { sql: "1 = 0", params: [] };
|
|
1932
|
+
}
|
|
1933
|
+
const placeholders = normalized.map(() => "?").join(", ");
|
|
1934
|
+
return {
|
|
1935
|
+
sql: `${quoteIdentifier("_meta_doc_id")} IN (${placeholders})`,
|
|
1936
|
+
params: normalized
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
|
|
1941
|
+
// src/engines/cloudflare/createDocumentDO.ts
|
|
1942
|
+
function createDatabaseDO(config = {}) {
|
|
1943
|
+
const hooks = config.hooks;
|
|
1944
|
+
return class DocumentDO {
|
|
1945
|
+
/** @internal */
|
|
1946
|
+
_doState;
|
|
1947
|
+
/** @internal */
|
|
1948
|
+
_engine;
|
|
1949
|
+
/** @internal */
|
|
1950
|
+
_initialized = false;
|
|
1951
|
+
/** @internal — cache for $contains misuse check: "model:field" keys known to be in data_json */
|
|
1952
|
+
_containsMisuseCache = /* @__PURE__ */ new Set();
|
|
1953
|
+
constructor(state, _env) {
|
|
1954
|
+
this._doState = state;
|
|
1955
|
+
this._engine = new DurableObjectEngine({
|
|
1956
|
+
sql: state.storage.sql,
|
|
1957
|
+
storage: state.storage
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
get state() {
|
|
1961
|
+
return this._doState;
|
|
1962
|
+
}
|
|
1963
|
+
get engine() {
|
|
1964
|
+
return this._engine;
|
|
1965
|
+
}
|
|
1966
|
+
/** @internal */
|
|
1967
|
+
async _ensureInitialized() {
|
|
1968
|
+
if (!this._initialized) {
|
|
1969
|
+
await this._engine.ensureReady();
|
|
1970
|
+
this._initialized = true;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
async fetch(request) {
|
|
1974
|
+
try {
|
|
1975
|
+
await this._ensureInitialized();
|
|
1976
|
+
const url = new URL(request.url);
|
|
1977
|
+
const path = url.pathname;
|
|
1978
|
+
const docId = url.searchParams.get("docId") || "";
|
|
1979
|
+
if (request.method === "DELETE" && path === "/destroy") {
|
|
1980
|
+
await this._doState.storage.deleteAll();
|
|
1981
|
+
this._initialized = false;
|
|
1982
|
+
return Response.json({
|
|
1983
|
+
deleted: true,
|
|
1984
|
+
deletedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
if (request.method !== "POST" && request.method !== "GET") {
|
|
1988
|
+
return this._errorResponse("Method not allowed", 405);
|
|
1989
|
+
}
|
|
1990
|
+
switch (path) {
|
|
1991
|
+
case "/query":
|
|
1992
|
+
return this._handleQuery(request, docId);
|
|
1993
|
+
case "/save":
|
|
1994
|
+
return this._handleSave(request, docId);
|
|
1995
|
+
case "/patch":
|
|
1996
|
+
return this._handlePatch(request, docId);
|
|
1997
|
+
case "/delete":
|
|
1998
|
+
return this._handleDelete(request, docId);
|
|
1999
|
+
case "/batch":
|
|
2000
|
+
return this._handleBatch(request, docId);
|
|
2001
|
+
case "/count":
|
|
2002
|
+
return this._handleCount(request, docId);
|
|
2003
|
+
case "/aggregate":
|
|
2004
|
+
return this._handleAggregate(request, docId);
|
|
2005
|
+
case "/increment":
|
|
2006
|
+
return this._handleIncrement(request, docId);
|
|
2007
|
+
case "/stringset/add":
|
|
2008
|
+
return this._handleStringSetAdd(request, docId);
|
|
2009
|
+
case "/stringset/remove":
|
|
2010
|
+
return this._handleStringSetRemove(request, docId);
|
|
2011
|
+
case "/indexes/sync":
|
|
2012
|
+
return this._handleIndexesSync(request);
|
|
2013
|
+
case "/index/register":
|
|
2014
|
+
return this._handleIndexRegister(request);
|
|
2015
|
+
case "/index/drop":
|
|
2016
|
+
return this._handleIndexDrop(request);
|
|
2017
|
+
case "/indexes":
|
|
2018
|
+
return this._handleIndexList(request);
|
|
2019
|
+
case "/unique-constraint/register":
|
|
2020
|
+
return this._handleUniqueConstraintRegister(request);
|
|
2021
|
+
case "/unique-constraint/drop":
|
|
2022
|
+
return this._handleUniqueConstraintDrop(request);
|
|
2023
|
+
case "/unique-constraints":
|
|
2024
|
+
return this._handleUniqueConstraintList(request);
|
|
2025
|
+
case "/health":
|
|
2026
|
+
return this._handleHealth();
|
|
2027
|
+
case "/describe":
|
|
2028
|
+
return this._handleDescribe(request);
|
|
2029
|
+
default:
|
|
2030
|
+
return this._errorResponse("Not found", 404);
|
|
2031
|
+
}
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
console.error("DO fetch error:", error);
|
|
2034
|
+
return this._errorResponse(
|
|
2035
|
+
error instanceof Error ? error.message : "Internal error",
|
|
2036
|
+
500
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
/** @internal */
|
|
2041
|
+
async _handleQuery(request, docId) {
|
|
2042
|
+
const body = await request.json();
|
|
2043
|
+
const { modelName, options } = body;
|
|
2044
|
+
let filter = body.filter || {};
|
|
2045
|
+
if (hooks?.beforeQuery) {
|
|
2046
|
+
const ctx = {
|
|
2047
|
+
modelName,
|
|
2048
|
+
docId,
|
|
2049
|
+
request,
|
|
2050
|
+
filter,
|
|
2051
|
+
options
|
|
2052
|
+
};
|
|
2053
|
+
const result = await hooks.beforeQuery(ctx);
|
|
2054
|
+
if (!result.allow) {
|
|
2055
|
+
return this._errorResponse(result.reason || "Query denied", 403);
|
|
2056
|
+
}
|
|
2057
|
+
if (result.injectFilter) {
|
|
2058
|
+
filter = { $and: [filter, result.injectFilter] };
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
const misuseError = this._checkContainsMisuse(modelName, filter);
|
|
2062
|
+
if (misuseError) return misuseError;
|
|
2063
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
2064
|
+
includeDocId: false
|
|
2065
|
+
});
|
|
2066
|
+
const { sql, params, sortFields } = translator.translateFind(filter, options);
|
|
2067
|
+
const rawResults = this._engine.execSqlSync(sql, params);
|
|
2068
|
+
const data = rawResults.map((row) => this._parseRow(row));
|
|
2069
|
+
const limit = options?.limit;
|
|
2070
|
+
const hasMore = CursorManager.hasMoreResults(limit, data.length);
|
|
2071
|
+
const isFirstPage = !options?.uniqueStartKey;
|
|
2072
|
+
const cursors = CursorManager.generateResultCursors(
|
|
2073
|
+
data,
|
|
2074
|
+
sortFields || ["id"],
|
|
2075
|
+
options?.direction || 1,
|
|
2076
|
+
hasMore,
|
|
2077
|
+
isFirstPage
|
|
2078
|
+
);
|
|
2079
|
+
const response = {
|
|
2080
|
+
data,
|
|
2081
|
+
hasMore,
|
|
2082
|
+
...cursors
|
|
2083
|
+
};
|
|
2084
|
+
return Response.json(response);
|
|
2085
|
+
}
|
|
2086
|
+
/** @internal */
|
|
2087
|
+
async _handleSave(request, docId) {
|
|
2088
|
+
const body = await request.json();
|
|
2089
|
+
const { modelName, id, data, stringSets, ifNotExists, condition } = body;
|
|
2090
|
+
if (!modelName || !id) {
|
|
2091
|
+
return this._errorResponse("modelName and id are required", 400);
|
|
2092
|
+
}
|
|
2093
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2094
|
+
return this._errorResponse(
|
|
2095
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2096
|
+
409
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
if (ifNotExists && this._engine.recordExists(modelName, id)) {
|
|
2100
|
+
return this._errorResponse(
|
|
2101
|
+
`Record ${modelName}/${id} already exists`,
|
|
2102
|
+
409
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
if (hooks?.beforeSave) {
|
|
2106
|
+
const isNew = !this._engine.recordExists(modelName, id);
|
|
2107
|
+
const ctx = {
|
|
2108
|
+
modelName,
|
|
2109
|
+
docId,
|
|
2110
|
+
request,
|
|
2111
|
+
id,
|
|
2112
|
+
data,
|
|
2113
|
+
stringSets,
|
|
2114
|
+
isNew
|
|
2115
|
+
};
|
|
2116
|
+
const result = await hooks.beforeSave(ctx);
|
|
2117
|
+
if (!result.allow) {
|
|
2118
|
+
return this._errorResponse(result.reason || "Save denied", 403);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2122
|
+
modelName,
|
|
2123
|
+
id,
|
|
2124
|
+
data
|
|
2125
|
+
);
|
|
2126
|
+
if (violation) {
|
|
2127
|
+
return this._errorResponse(violation, 409);
|
|
2128
|
+
}
|
|
2129
|
+
if (stringSets && Object.keys(stringSets).length > 0) {
|
|
2130
|
+
await this._engine.insertWithStringSets(
|
|
2131
|
+
modelName,
|
|
2132
|
+
{ id, ...data },
|
|
2133
|
+
stringSets
|
|
2134
|
+
);
|
|
2135
|
+
} else {
|
|
2136
|
+
await this._engine.insert(modelName, { id, ...data });
|
|
2137
|
+
}
|
|
2138
|
+
this._engine.trackModelFields(modelName, data);
|
|
2139
|
+
const response = { success: true, id };
|
|
2140
|
+
return Response.json(response);
|
|
2141
|
+
}
|
|
2142
|
+
/** @internal — Partial update: merge provided fields into existing record */
|
|
2143
|
+
async _handlePatch(request, docId) {
|
|
2144
|
+
const body = await request.json();
|
|
2145
|
+
const { modelName, id, data, stringSets, condition } = body;
|
|
2146
|
+
if (!modelName || !id || !data) {
|
|
2147
|
+
return this._errorResponse("modelName, id, and data are required", 400);
|
|
2148
|
+
}
|
|
2149
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2150
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2151
|
+
}
|
|
2152
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2153
|
+
return this._errorResponse(
|
|
2154
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2155
|
+
409
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
if (hooks?.beforeSave) {
|
|
2159
|
+
const ctx = {
|
|
2160
|
+
modelName,
|
|
2161
|
+
docId,
|
|
2162
|
+
request,
|
|
2163
|
+
id,
|
|
2164
|
+
data,
|
|
2165
|
+
stringSets,
|
|
2166
|
+
isNew: false
|
|
2167
|
+
};
|
|
2168
|
+
const result = await hooks.beforeSave(ctx);
|
|
2169
|
+
if (!result.allow) {
|
|
2170
|
+
return this._errorResponse(result.reason || "Save denied", 403);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
const existing = this._engine.execSqlSync(
|
|
2174
|
+
"SELECT data_json FROM records WHERE id = ? AND type = ?",
|
|
2175
|
+
[id, modelName]
|
|
2176
|
+
);
|
|
2177
|
+
if (existing.length > 0) {
|
|
2178
|
+
const existingData = JSON.parse(existing[0].data_json);
|
|
2179
|
+
const mergedData = { ...existingData, ...data };
|
|
2180
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2181
|
+
modelName,
|
|
2182
|
+
id,
|
|
2183
|
+
mergedData
|
|
2184
|
+
);
|
|
2185
|
+
if (violation) {
|
|
2186
|
+
return this._errorResponse(violation, 409);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const found = this._engine.patchRecord(modelName, id, data, stringSets);
|
|
2190
|
+
if (!found) {
|
|
2191
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2192
|
+
}
|
|
2193
|
+
this._engine.trackModelFields(modelName, data);
|
|
2194
|
+
const response = { success: true, id };
|
|
2195
|
+
return Response.json(response);
|
|
2196
|
+
}
|
|
2197
|
+
/** @internal */
|
|
2198
|
+
async _handleDelete(request, docId) {
|
|
2199
|
+
const body = await request.json();
|
|
2200
|
+
const { modelName, id, condition } = body;
|
|
2201
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2202
|
+
return this._errorResponse(
|
|
2203
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2204
|
+
409
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
if (hooks?.beforeDelete) {
|
|
2208
|
+
const ctx = {
|
|
2209
|
+
modelName,
|
|
2210
|
+
docId,
|
|
2211
|
+
request,
|
|
2212
|
+
id
|
|
2213
|
+
};
|
|
2214
|
+
const result = await hooks.beforeDelete(ctx);
|
|
2215
|
+
if (!result.allow) {
|
|
2216
|
+
return this._errorResponse(result.reason || "Delete denied", 403);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
await this._engine.deleteWithStringSets(modelName, id);
|
|
2220
|
+
const response = { success: true };
|
|
2221
|
+
return Response.json(response);
|
|
2222
|
+
}
|
|
2223
|
+
/** @internal — Execute multiple save/patch/delete operations in a single transaction */
|
|
2224
|
+
async _handleBatch(request, docId) {
|
|
2225
|
+
const body = await request.json();
|
|
2226
|
+
const { operations } = body;
|
|
2227
|
+
if (!operations || !Array.isArray(operations) || operations.length === 0) {
|
|
2228
|
+
return this._errorResponse("operations array is required and must not be empty", 400);
|
|
2229
|
+
}
|
|
2230
|
+
const hookDenials = /* @__PURE__ */ new Map();
|
|
2231
|
+
for (let i = 0; i < operations.length; i++) {
|
|
2232
|
+
const op = operations[i];
|
|
2233
|
+
if (!op.modelName || !op.id || !op.op) {
|
|
2234
|
+
return this._errorResponse(
|
|
2235
|
+
`Operation ${i}: modelName, id, and op are required`,
|
|
2236
|
+
400
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
if (op.op === "save" || op.op === "patch") {
|
|
2240
|
+
if (!op.data) {
|
|
2241
|
+
return this._errorResponse(
|
|
2242
|
+
`Operation ${i}: data is required for ${op.op}`,
|
|
2243
|
+
400
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
if (hooks?.beforeSave) {
|
|
2247
|
+
const isNew = op.op === "save" ? !this._engine.recordExists(op.modelName, op.id) : false;
|
|
2248
|
+
const ctx = {
|
|
2249
|
+
modelName: op.modelName,
|
|
2250
|
+
docId,
|
|
2251
|
+
request,
|
|
2252
|
+
id: op.id,
|
|
2253
|
+
data: op.data,
|
|
2254
|
+
stringSets: op.stringSets,
|
|
2255
|
+
isNew
|
|
2256
|
+
};
|
|
2257
|
+
const result = await hooks.beforeSave(ctx);
|
|
2258
|
+
if (!result.allow) {
|
|
2259
|
+
hookDenials.set(i, result.reason || "Save denied");
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
} else if (op.op === "delete") {
|
|
2263
|
+
if (hooks?.beforeDelete) {
|
|
2264
|
+
const ctx = {
|
|
2265
|
+
modelName: op.modelName,
|
|
2266
|
+
docId,
|
|
2267
|
+
request,
|
|
2268
|
+
id: op.id
|
|
2269
|
+
};
|
|
2270
|
+
const result = await hooks.beforeDelete(ctx);
|
|
2271
|
+
if (!result.allow) {
|
|
2272
|
+
hookDenials.set(i, result.reason || "Delete denied");
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
} else if (op.op === "increment") {
|
|
2276
|
+
if (!op.fields || typeof op.fields !== "object") {
|
|
2277
|
+
return this._errorResponse(
|
|
2278
|
+
`Operation ${i}: fields is required for increment`,
|
|
2279
|
+
400
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
} else if (op.op === "addToSet" || op.op === "removeFromSet") {
|
|
2283
|
+
if (!op.stringSets || typeof op.stringSets !== "object") {
|
|
2284
|
+
return this._errorResponse(
|
|
2285
|
+
`Operation ${i}: stringSets is required for ${op.op}`,
|
|
2286
|
+
400
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
} else {
|
|
2290
|
+
return this._errorResponse(
|
|
2291
|
+
`Operation ${i}: unknown op '${op.op}'.`,
|
|
2292
|
+
400
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
const results = [];
|
|
2297
|
+
const fieldsToTrack = [];
|
|
2298
|
+
this._engine.transactionSync(() => {
|
|
2299
|
+
for (let i = 0; i < operations.length; i++) {
|
|
2300
|
+
const op = operations[i];
|
|
2301
|
+
if (hookDenials.has(i)) {
|
|
2302
|
+
results.push({
|
|
2303
|
+
success: false,
|
|
2304
|
+
id: op.id,
|
|
2305
|
+
error: hookDenials.get(i)
|
|
2306
|
+
});
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
if (op.condition && !this._checkCondition(op.modelName, op.id, op.condition)) {
|
|
2310
|
+
results.push({
|
|
2311
|
+
success: false,
|
|
2312
|
+
id: op.id,
|
|
2313
|
+
error: `Condition not met for ${op.modelName}/${op.id}`
|
|
2314
|
+
});
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
if (op.op === "save") {
|
|
2318
|
+
if (op.ifNotExists && this._engine.recordExists(op.modelName, op.id)) {
|
|
2319
|
+
results.push({
|
|
2320
|
+
success: false,
|
|
2321
|
+
id: op.id,
|
|
2322
|
+
error: `Record ${op.modelName}/${op.id} already exists`
|
|
2323
|
+
});
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
2326
|
+
if (op.data) {
|
|
2327
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2328
|
+
op.modelName,
|
|
2329
|
+
op.id,
|
|
2330
|
+
op.data
|
|
2331
|
+
);
|
|
2332
|
+
if (violation) {
|
|
2333
|
+
results.push({
|
|
2334
|
+
success: false,
|
|
2335
|
+
id: op.id,
|
|
2336
|
+
error: violation
|
|
2337
|
+
});
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
if (op.stringSets && Object.keys(op.stringSets).length > 0) {
|
|
2342
|
+
const { id: _id, type: _type, ...fieldsForJson } = op.data;
|
|
2343
|
+
const jsonFields = { ...fieldsForJson, ...op.stringSets };
|
|
2344
|
+
const dataJson = JSON.stringify(jsonFields);
|
|
2345
|
+
this._engine.execSqlSync(
|
|
2346
|
+
"INSERT OR REPLACE INTO records (id, type, data_json) VALUES (?, ?, ?)",
|
|
2347
|
+
[op.id, op.modelName, dataJson]
|
|
2348
|
+
);
|
|
2349
|
+
this._engine.execSqlSync(
|
|
2350
|
+
"DELETE FROM stringset_index WHERE type = ? AND record_id = ?",
|
|
2351
|
+
[op.modelName, op.id]
|
|
2352
|
+
);
|
|
2353
|
+
for (const [fieldName, values] of Object.entries(op.stringSets)) {
|
|
2354
|
+
for (const value of values) {
|
|
2355
|
+
this._engine.execSqlSync(
|
|
2356
|
+
"INSERT OR IGNORE INTO stringset_index (record_id, type, field, value) VALUES (?, ?, ?, ?)",
|
|
2357
|
+
[op.id, op.modelName, fieldName, value]
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
} else {
|
|
2362
|
+
const { id: _id, type: _type, ...fieldsForJson } = op.data;
|
|
2363
|
+
const dataJson = JSON.stringify(fieldsForJson);
|
|
2364
|
+
this._engine.execSqlSync(
|
|
2365
|
+
"INSERT OR REPLACE INTO records (id, type, data_json) VALUES (?, ?, ?)",
|
|
2366
|
+
[op.id, op.modelName, dataJson]
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
|
|
2370
|
+
results.push({ success: true, id: op.id });
|
|
2371
|
+
} else if (op.op === "patch") {
|
|
2372
|
+
if (!this._engine.recordExists(op.modelName, op.id)) {
|
|
2373
|
+
results.push({
|
|
2374
|
+
success: false,
|
|
2375
|
+
id: op.id,
|
|
2376
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2377
|
+
});
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
if (op.data) {
|
|
2381
|
+
const existing = this._engine.execSqlSync(
|
|
2382
|
+
"SELECT data_json FROM records WHERE id = ? AND type = ?",
|
|
2383
|
+
[op.id, op.modelName]
|
|
2384
|
+
);
|
|
2385
|
+
if (existing.length > 0) {
|
|
2386
|
+
const existingData = JSON.parse(existing[0].data_json);
|
|
2387
|
+
const mergedData = { ...existingData, ...op.data };
|
|
2388
|
+
const violation = this._engine.checkUniqueConstraints(
|
|
2389
|
+
op.modelName,
|
|
2390
|
+
op.id,
|
|
2391
|
+
mergedData
|
|
2392
|
+
);
|
|
2393
|
+
if (violation) {
|
|
2394
|
+
results.push({
|
|
2395
|
+
success: false,
|
|
2396
|
+
id: op.id,
|
|
2397
|
+
error: violation
|
|
2398
|
+
});
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
const found = this._engine.patchRecordRaw(
|
|
2404
|
+
op.modelName,
|
|
2405
|
+
op.id,
|
|
2406
|
+
op.data,
|
|
2407
|
+
op.stringSets
|
|
2408
|
+
);
|
|
2409
|
+
if (!found) {
|
|
2410
|
+
results.push({
|
|
2411
|
+
success: false,
|
|
2412
|
+
id: op.id,
|
|
2413
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2414
|
+
});
|
|
2415
|
+
} else {
|
|
2416
|
+
if (op.data) fieldsToTrack.push({ modelName: op.modelName, data: op.data });
|
|
2417
|
+
results.push({ success: true, id: op.id });
|
|
2418
|
+
}
|
|
2419
|
+
} else if (op.op === "delete") {
|
|
2420
|
+
this._engine.execSqlSync(
|
|
2421
|
+
"DELETE FROM records WHERE id = ? AND type = ?",
|
|
2422
|
+
[op.id, op.modelName]
|
|
2423
|
+
);
|
|
2424
|
+
this._engine.execSqlSync(
|
|
2425
|
+
"DELETE FROM stringset_index WHERE record_id = ? AND type = ?",
|
|
2426
|
+
[op.id, op.modelName]
|
|
2427
|
+
);
|
|
2428
|
+
results.push({ success: true, id: op.id });
|
|
2429
|
+
} else if (op.op === "increment") {
|
|
2430
|
+
const newValues = this._engine.incrementFieldsRaw(
|
|
2431
|
+
op.modelName,
|
|
2432
|
+
op.id,
|
|
2433
|
+
op.fields
|
|
2434
|
+
);
|
|
2435
|
+
if (!newValues) {
|
|
2436
|
+
results.push({
|
|
2437
|
+
success: false,
|
|
2438
|
+
id: op.id,
|
|
2439
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2440
|
+
});
|
|
2441
|
+
} else {
|
|
2442
|
+
results.push({ success: true, id: op.id, values: newValues });
|
|
2443
|
+
}
|
|
2444
|
+
} else if (op.op === "addToSet") {
|
|
2445
|
+
if (!this._engine.recordExists(op.modelName, op.id)) {
|
|
2446
|
+
results.push({
|
|
2447
|
+
success: false,
|
|
2448
|
+
id: op.id,
|
|
2449
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2450
|
+
});
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2453
|
+
this._engine.addToStringSetsRaw(
|
|
2454
|
+
op.modelName,
|
|
2455
|
+
op.id,
|
|
2456
|
+
op.stringSets
|
|
2457
|
+
);
|
|
2458
|
+
results.push({ success: true, id: op.id });
|
|
2459
|
+
} else if (op.op === "removeFromSet") {
|
|
2460
|
+
if (!this._engine.recordExists(op.modelName, op.id)) {
|
|
2461
|
+
results.push({
|
|
2462
|
+
success: false,
|
|
2463
|
+
id: op.id,
|
|
2464
|
+
error: `Record ${op.modelName}/${op.id} not found`
|
|
2465
|
+
});
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
this._engine.removeFromStringSetsRaw(
|
|
2469
|
+
op.modelName,
|
|
2470
|
+
op.id,
|
|
2471
|
+
op.stringSets
|
|
2472
|
+
);
|
|
2473
|
+
results.push({ success: true, id: op.id });
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
for (const entry of fieldsToTrack) {
|
|
2478
|
+
this._engine.trackModelFields(entry.modelName, entry.data);
|
|
2479
|
+
}
|
|
2480
|
+
const response = { results };
|
|
2481
|
+
return Response.json(response);
|
|
2482
|
+
}
|
|
2483
|
+
/** @internal */
|
|
2484
|
+
async _handleCount(request, docId) {
|
|
2485
|
+
const body = await request.json();
|
|
2486
|
+
const { modelName } = body;
|
|
2487
|
+
let filter = body.filter || {};
|
|
2488
|
+
if (hooks?.beforeQuery) {
|
|
2489
|
+
const ctx = {
|
|
2490
|
+
modelName,
|
|
2491
|
+
docId,
|
|
2492
|
+
request,
|
|
2493
|
+
filter
|
|
2494
|
+
};
|
|
2495
|
+
const result = await hooks.beforeQuery(ctx);
|
|
2496
|
+
if (!result.allow) {
|
|
2497
|
+
return this._errorResponse(result.reason || "Query denied", 403);
|
|
2498
|
+
}
|
|
2499
|
+
if (result.injectFilter) {
|
|
2500
|
+
filter = { $and: [filter, result.injectFilter] };
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
const misuseError = this._checkContainsMisuse(modelName, filter);
|
|
2504
|
+
if (misuseError) return misuseError;
|
|
2505
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
2506
|
+
includeDocId: false
|
|
2507
|
+
});
|
|
2508
|
+
const { sql, params } = translator.translateCount(filter);
|
|
2509
|
+
const results = this._engine.execSqlSync(sql, params);
|
|
2510
|
+
const response = { count: results[0]?.count ?? 0 };
|
|
2511
|
+
return Response.json(response);
|
|
2512
|
+
}
|
|
2513
|
+
/** @internal — Atomically increment/decrement numeric fields */
|
|
2514
|
+
async _handleIncrement(request, _docId) {
|
|
2515
|
+
const body = await request.json();
|
|
2516
|
+
const { modelName, id, fields, condition } = body;
|
|
2517
|
+
if (!modelName || !id || !fields) {
|
|
2518
|
+
return this._errorResponse("modelName, id, and fields are required", 400);
|
|
2519
|
+
}
|
|
2520
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2521
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2522
|
+
}
|
|
2523
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2524
|
+
return this._errorResponse(
|
|
2525
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2526
|
+
409
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
const newValues = this._engine.incrementFields(modelName, id, fields);
|
|
2530
|
+
if (!newValues) {
|
|
2531
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2532
|
+
}
|
|
2533
|
+
const response = { success: true, id, values: newValues };
|
|
2534
|
+
return Response.json(response);
|
|
2535
|
+
}
|
|
2536
|
+
/** @internal — Atomically add values to StringSet fields */
|
|
2537
|
+
async _handleStringSetAdd(request, _docId) {
|
|
2538
|
+
const body = await request.json();
|
|
2539
|
+
const { modelName, id, sets, condition } = body;
|
|
2540
|
+
if (!modelName || !id || !sets) {
|
|
2541
|
+
return this._errorResponse("modelName, id, and sets are required", 400);
|
|
2542
|
+
}
|
|
2543
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2544
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2545
|
+
}
|
|
2546
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2547
|
+
return this._errorResponse(
|
|
2548
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2549
|
+
409
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
this._engine.addToStringSets(modelName, id, sets);
|
|
2553
|
+
const response = { success: true };
|
|
2554
|
+
return Response.json(response);
|
|
2555
|
+
}
|
|
2556
|
+
/** @internal — Atomically remove values from StringSet fields */
|
|
2557
|
+
async _handleStringSetRemove(request, _docId) {
|
|
2558
|
+
const body = await request.json();
|
|
2559
|
+
const { modelName, id, sets, condition } = body;
|
|
2560
|
+
if (!modelName || !id || !sets) {
|
|
2561
|
+
return this._errorResponse("modelName, id, and sets are required", 400);
|
|
2562
|
+
}
|
|
2563
|
+
if (!this._engine.recordExists(modelName, id)) {
|
|
2564
|
+
return this._errorResponse(`Record ${modelName}/${id} not found`, 404);
|
|
2565
|
+
}
|
|
2566
|
+
if (condition && !this._checkCondition(modelName, id, condition)) {
|
|
2567
|
+
return this._errorResponse(
|
|
2568
|
+
`Condition not met for ${modelName}/${id}`,
|
|
2569
|
+
409
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
this._engine.removeFromStringSets(modelName, id, sets);
|
|
2573
|
+
const response = { success: true };
|
|
2574
|
+
return Response.json(response);
|
|
2575
|
+
}
|
|
2576
|
+
async _handleAggregate(request, docId) {
|
|
2577
|
+
const body = await request.json();
|
|
2578
|
+
const { modelName, options: aggOptions } = body;
|
|
2579
|
+
if (!modelName || !aggOptions || !aggOptions.groupBy || !aggOptions.operations) {
|
|
2580
|
+
return this._errorResponse(
|
|
2581
|
+
"modelName, options.groupBy, and options.operations are required",
|
|
2582
|
+
400
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
for (const op of aggOptions.operations) {
|
|
2586
|
+
if (op.type !== "count" && !op.field) {
|
|
2587
|
+
return this._errorResponse(
|
|
2588
|
+
`Operation '${op.type}' requires a field parameter`,
|
|
2589
|
+
400
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
if (op.type === "count" && op.field) {
|
|
2593
|
+
return this._errorResponse(
|
|
2594
|
+
`Operation 'count' should not have a field parameter`,
|
|
2595
|
+
400
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
let filter = aggOptions.filter || {};
|
|
2600
|
+
if (hooks?.beforeQuery) {
|
|
2601
|
+
const ctx = {
|
|
2602
|
+
modelName,
|
|
2603
|
+
docId,
|
|
2604
|
+
request,
|
|
2605
|
+
filter
|
|
2606
|
+
};
|
|
2607
|
+
const result = await hooks.beforeQuery(ctx);
|
|
2608
|
+
if (!result.allow) {
|
|
2609
|
+
return this._errorResponse(result.reason || "Query denied", 403);
|
|
2610
|
+
}
|
|
2611
|
+
if (result.injectFilter) {
|
|
2612
|
+
filter = { $and: [filter, result.injectFilter] };
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
const misuseError = this._checkContainsMisuse(modelName, filter);
|
|
2616
|
+
if (misuseError) return misuseError;
|
|
2617
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
2618
|
+
includeDocId: false
|
|
2619
|
+
});
|
|
2620
|
+
const regularGroupBy = [];
|
|
2621
|
+
const stringSetMemberships = [];
|
|
2622
|
+
let stringSetFacetField = null;
|
|
2623
|
+
for (const groupBy of aggOptions.groupBy) {
|
|
2624
|
+
if (typeof groupBy === "string") {
|
|
2625
|
+
regularGroupBy.push(groupBy);
|
|
2626
|
+
} else {
|
|
2627
|
+
assertValidIdentifier(groupBy.field, "aggregation groupBy field");
|
|
2628
|
+
stringSetMemberships.push(groupBy);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const confirmedRegular = [];
|
|
2632
|
+
for (const field of regularGroupBy) {
|
|
2633
|
+
assertValidIdentifier(field, "aggregation groupBy field");
|
|
2634
|
+
const ssCheck = this._engine.execSqlSync(
|
|
2635
|
+
"SELECT 1 FROM stringset_index WHERE type = ? AND field = ? LIMIT 1",
|
|
2636
|
+
[modelName, field]
|
|
2637
|
+
);
|
|
2638
|
+
if (ssCheck.length > 0) {
|
|
2639
|
+
if (stringSetFacetField !== null) {
|
|
2640
|
+
return this._errorResponse(
|
|
2641
|
+
"Multiple StringSet facet fields not supported in single aggregation",
|
|
2642
|
+
400
|
|
2643
|
+
);
|
|
2644
|
+
}
|
|
2645
|
+
stringSetFacetField = field;
|
|
2646
|
+
} else {
|
|
2647
|
+
confirmedRegular.push(field);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
try {
|
|
2651
|
+
let sql;
|
|
2652
|
+
let params = [];
|
|
2653
|
+
const aliasMap = [];
|
|
2654
|
+
if (stringSetFacetField && confirmedRegular.length === 0 && stringSetMemberships.length === 0) {
|
|
2655
|
+
const result = this._buildStringSetFacetSql(
|
|
2656
|
+
modelName,
|
|
2657
|
+
stringSetFacetField,
|
|
2658
|
+
aggOptions,
|
|
2659
|
+
filter,
|
|
2660
|
+
translator
|
|
2661
|
+
);
|
|
2662
|
+
sql = result.sql;
|
|
2663
|
+
params = result.params;
|
|
2664
|
+
} else {
|
|
2665
|
+
const result = this._buildRegularAggregationSql(
|
|
2666
|
+
modelName,
|
|
2667
|
+
confirmedRegular,
|
|
2668
|
+
stringSetMemberships,
|
|
2669
|
+
aggOptions,
|
|
2670
|
+
filter,
|
|
2671
|
+
translator
|
|
2672
|
+
);
|
|
2673
|
+
sql = result.sql;
|
|
2674
|
+
params = result.params;
|
|
2675
|
+
aliasMap.push(...result.aliasMap);
|
|
2676
|
+
}
|
|
2677
|
+
const rawResults = this._engine.execSqlSync(sql, params);
|
|
2678
|
+
const processed = this._processAggregationResults(rawResults, aggOptions, aliasMap, stringSetFacetField);
|
|
2679
|
+
const response = { result: processed };
|
|
2680
|
+
return Response.json(response);
|
|
2681
|
+
} catch (err) {
|
|
2682
|
+
return this._errorResponse(
|
|
2683
|
+
err instanceof Error ? err.message : "Aggregation failed",
|
|
2684
|
+
500
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
/** @internal — Build SQL for StringSet facet aggregation */
|
|
2689
|
+
_buildStringSetFacetSql(modelName, facetField, aggOptions, filter, translator) {
|
|
2690
|
+
const selectParts = ["stringset_index.value AS group_key"];
|
|
2691
|
+
for (const op of aggOptions.operations) {
|
|
2692
|
+
switch (op.type) {
|
|
2693
|
+
case "count":
|
|
2694
|
+
selectParts.push("COUNT(*) AS count");
|
|
2695
|
+
break;
|
|
2696
|
+
case "sum":
|
|
2697
|
+
selectParts.push(`SUM(${translator.getFieldSql(op.field)}) AS sum_${op.field}`);
|
|
2698
|
+
break;
|
|
2699
|
+
case "avg":
|
|
2700
|
+
selectParts.push(`AVG(${translator.getFieldSql(op.field)}) AS avg_${op.field}`);
|
|
2701
|
+
break;
|
|
2702
|
+
case "min":
|
|
2703
|
+
selectParts.push(`MIN(${translator.getFieldSql(op.field)}) AS min_${op.field}`);
|
|
2704
|
+
break;
|
|
2705
|
+
case "max":
|
|
2706
|
+
selectParts.push(`MAX(${translator.getFieldSql(op.field)}) AS max_${op.field}`);
|
|
2707
|
+
break;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
let sql = `SELECT ${selectParts.join(", ")} FROM stringset_index`;
|
|
2711
|
+
sql += ` INNER JOIN records ON stringset_index.record_id = records.id AND records.type = stringset_index.type`;
|
|
2712
|
+
const conditions = [
|
|
2713
|
+
"stringset_index.type = ?",
|
|
2714
|
+
"stringset_index.field = ?"
|
|
2715
|
+
];
|
|
2716
|
+
const params = [modelName, facetField];
|
|
2717
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
2718
|
+
const whereClause = translator.translateFilter(filter);
|
|
2719
|
+
if (whereClause.sql) {
|
|
2720
|
+
conditions.push(whereClause.sql);
|
|
2721
|
+
params.push(...whereClause.params);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
2725
|
+
sql += " GROUP BY stringset_index.value";
|
|
2726
|
+
if (aggOptions.sort) {
|
|
2727
|
+
const sortExpr = aggOptions.sort.field === "count" ? "COUNT(*)" : aggOptions.sort.field;
|
|
2728
|
+
const dir = aggOptions.sort.direction === 1 ? "ASC" : "DESC";
|
|
2729
|
+
sql += ` ORDER BY ${sortExpr} ${dir}`;
|
|
2730
|
+
}
|
|
2731
|
+
if (aggOptions.limit) {
|
|
2732
|
+
sql += ` LIMIT ${Number(aggOptions.limit)}`;
|
|
2733
|
+
}
|
|
2734
|
+
return { sql, params };
|
|
2735
|
+
}
|
|
2736
|
+
/** @internal — Build SQL for regular field aggregation */
|
|
2737
|
+
_buildRegularAggregationSql(modelName, regularGroupBy, stringSetMemberships, aggOptions, filter, translator) {
|
|
2738
|
+
const selectParts = [];
|
|
2739
|
+
const groupByParts = [];
|
|
2740
|
+
const aliasMap = [];
|
|
2741
|
+
for (const field of regularGroupBy) {
|
|
2742
|
+
const fieldSql = translator.getFieldSql(field);
|
|
2743
|
+
selectParts.push(`${fieldSql} AS "${field}"`);
|
|
2744
|
+
groupByParts.push(fieldSql);
|
|
2745
|
+
}
|
|
2746
|
+
for (let i = 0; i < stringSetMemberships.length; i++) {
|
|
2747
|
+
const m = stringSetMemberships[i];
|
|
2748
|
+
const alias = `ss_${m.field}_${i}`;
|
|
2749
|
+
selectParts.push(
|
|
2750
|
+
`CASE WHEN ${alias}.record_id IS NOT NULL THEN 'true' ELSE 'false' END AS "${alias}"`
|
|
2751
|
+
);
|
|
2752
|
+
groupByParts.push(`CASE WHEN ${alias}.record_id IS NOT NULL THEN 'true' ELSE 'false' END`);
|
|
2753
|
+
aliasMap.push({ alias, field: m.field, contains: m.contains });
|
|
2754
|
+
}
|
|
2755
|
+
for (const op of aggOptions.operations) {
|
|
2756
|
+
switch (op.type) {
|
|
2757
|
+
case "count":
|
|
2758
|
+
selectParts.push("COUNT(*) AS count");
|
|
2759
|
+
break;
|
|
2760
|
+
case "sum":
|
|
2761
|
+
selectParts.push(`SUM(${translator.getFieldSql(op.field)}) AS sum_${op.field}`);
|
|
2762
|
+
break;
|
|
2763
|
+
case "avg":
|
|
2764
|
+
selectParts.push(`AVG(${translator.getFieldSql(op.field)}) AS avg_${op.field}`);
|
|
2765
|
+
break;
|
|
2766
|
+
case "min":
|
|
2767
|
+
selectParts.push(`MIN(${translator.getFieldSql(op.field)}) AS min_${op.field}`);
|
|
2768
|
+
break;
|
|
2769
|
+
case "max":
|
|
2770
|
+
selectParts.push(`MAX(${translator.getFieldSql(op.field)}) AS max_${op.field}`);
|
|
2771
|
+
break;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
let sql = `SELECT ${selectParts.join(", ")} FROM records`;
|
|
2775
|
+
const joinParams = [];
|
|
2776
|
+
for (const entry of aliasMap) {
|
|
2777
|
+
sql += ` LEFT JOIN stringset_index AS ${entry.alias}`;
|
|
2778
|
+
sql += ` ON ${entry.alias}.record_id = records.id`;
|
|
2779
|
+
sql += ` AND ${entry.alias}.type = records.type`;
|
|
2780
|
+
sql += ` AND ${entry.alias}.field = ?`;
|
|
2781
|
+
sql += ` AND ${entry.alias}.value = ?`;
|
|
2782
|
+
joinParams.push(entry.field, entry.contains);
|
|
2783
|
+
}
|
|
2784
|
+
const conditions = ["records.type = ?"];
|
|
2785
|
+
const params = [...joinParams, modelName];
|
|
2786
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
2787
|
+
const whereClause = translator.translateFilter(filter);
|
|
2788
|
+
if (whereClause.sql) {
|
|
2789
|
+
conditions.push(whereClause.sql);
|
|
2790
|
+
params.push(...whereClause.params);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
2794
|
+
if (groupByParts.length > 0) {
|
|
2795
|
+
sql += ` GROUP BY ${groupByParts.join(", ")}`;
|
|
2796
|
+
}
|
|
2797
|
+
if (aggOptions.sort) {
|
|
2798
|
+
const sortField = aggOptions.sort.field;
|
|
2799
|
+
let sortExpr;
|
|
2800
|
+
if (sortField === "count") {
|
|
2801
|
+
sortExpr = "COUNT(*)";
|
|
2802
|
+
} else if (sortField.startsWith("sum_") || sortField.startsWith("avg_") || sortField.startsWith("min_") || sortField.startsWith("max_")) {
|
|
2803
|
+
sortExpr = `"${sortField}"`;
|
|
2804
|
+
} else {
|
|
2805
|
+
const aliasEntry = aliasMap.find((a) => a.alias === sortField);
|
|
2806
|
+
sortExpr = aliasEntry ? `"${sortField}"` : translator.getFieldSql(sortField);
|
|
2807
|
+
}
|
|
2808
|
+
const dir = aggOptions.sort.direction === 1 ? "ASC" : "DESC";
|
|
2809
|
+
sql += ` ORDER BY ${sortExpr} ${dir}`;
|
|
2810
|
+
}
|
|
2811
|
+
if (aggOptions.limit) {
|
|
2812
|
+
sql += ` LIMIT ${Number(aggOptions.limit)}`;
|
|
2813
|
+
}
|
|
2814
|
+
return { sql, params, aliasMap };
|
|
2815
|
+
}
|
|
2816
|
+
/** @internal — Extract operation value(s) from a row. Flattens single-op results. */
|
|
2817
|
+
_extractOpValue(row, operations) {
|
|
2818
|
+
if (operations.length === 1) {
|
|
2819
|
+
const op = operations[0];
|
|
2820
|
+
if (op.type === "count") return row.count;
|
|
2821
|
+
return row[`${op.type}_${op.field}`];
|
|
2822
|
+
}
|
|
2823
|
+
const result = {};
|
|
2824
|
+
for (const op of operations) {
|
|
2825
|
+
if (op.type === "count") {
|
|
2826
|
+
result.count = row.count;
|
|
2827
|
+
} else {
|
|
2828
|
+
result[`${op.type}_${op.field}`] = row[`${op.type}_${op.field}`];
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
return result;
|
|
2832
|
+
}
|
|
2833
|
+
/** @internal — Process raw aggregation rows into nested result */
|
|
2834
|
+
_processAggregationResults(results, aggOptions, aliasMap, stringSetFacetField) {
|
|
2835
|
+
if (results.length === 0) return {};
|
|
2836
|
+
if (stringSetFacetField !== null) {
|
|
2837
|
+
const facetResult = {};
|
|
2838
|
+
for (const row of results) {
|
|
2839
|
+
const key = row.group_key;
|
|
2840
|
+
facetResult[key] = this._extractOpValue(row, aggOptions.operations);
|
|
2841
|
+
}
|
|
2842
|
+
return facetResult;
|
|
2843
|
+
}
|
|
2844
|
+
const nestedResult = {};
|
|
2845
|
+
const aliasLookup = /* @__PURE__ */ new Map();
|
|
2846
|
+
for (const entry of aliasMap) {
|
|
2847
|
+
const membershipKey = `${entry.field}::${entry.contains}`;
|
|
2848
|
+
aliasLookup.set(membershipKey, entry.alias);
|
|
2849
|
+
}
|
|
2850
|
+
for (const row of results) {
|
|
2851
|
+
let current = nestedResult;
|
|
2852
|
+
for (let i = 0; i < aggOptions.groupBy.length; i++) {
|
|
2853
|
+
const groupBy = aggOptions.groupBy[i];
|
|
2854
|
+
let key;
|
|
2855
|
+
if (typeof groupBy === "string") {
|
|
2856
|
+
key = String(row[groupBy]);
|
|
2857
|
+
} else {
|
|
2858
|
+
const membershipKey = `${groupBy.field}::${groupBy.contains}`;
|
|
2859
|
+
const alias = aliasLookup.get(membershipKey);
|
|
2860
|
+
key = alias ? row[alias] : "unknown";
|
|
2861
|
+
}
|
|
2862
|
+
if (i === aggOptions.groupBy.length - 1) {
|
|
2863
|
+
current[key] = this._extractOpValue(row, aggOptions.operations);
|
|
2864
|
+
} else {
|
|
2865
|
+
if (!current[key]) current[key] = {};
|
|
2866
|
+
current = current[key];
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
return nestedResult;
|
|
2871
|
+
}
|
|
2872
|
+
/** @internal */
|
|
2873
|
+
/** @internal — Batch sync: compare desired indexes against _indexes table, register missing */
|
|
2874
|
+
async _handleIndexesSync(request) {
|
|
2875
|
+
const body = await request.json();
|
|
2876
|
+
if (!body.models || !Array.isArray(body.models)) {
|
|
2877
|
+
return this._errorResponse("models array is required", 400);
|
|
2878
|
+
}
|
|
2879
|
+
let registered = 0;
|
|
2880
|
+
for (const model of body.models) {
|
|
2881
|
+
if (!model.modelName) continue;
|
|
2882
|
+
const existingIndexes = this._engine.listIndexes(model.modelName);
|
|
2883
|
+
const existingIndexMap = new Map(
|
|
2884
|
+
existingIndexes.map((idx) => [idx.field_name, idx])
|
|
2885
|
+
);
|
|
2886
|
+
const existingConstraints = this._engine.listUniqueConstraints(model.modelName);
|
|
2887
|
+
const existingConstraintSet = new Set(
|
|
2888
|
+
existingConstraints.map((c) => c.constraint_name)
|
|
2889
|
+
);
|
|
2890
|
+
for (const idx of model.indexes || []) {
|
|
2891
|
+
const existing = existingIndexMap.get(idx.fieldName);
|
|
2892
|
+
if (existing) {
|
|
2893
|
+
if (idx.unique && !existing.is_unique) {
|
|
2894
|
+
this._engine.registerIndex(model.modelName, idx.fieldName, idx.fieldType, true);
|
|
2895
|
+
registered++;
|
|
2896
|
+
}
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
this._engine.registerIndex(model.modelName, idx.fieldName, idx.fieldType, idx.unique);
|
|
2900
|
+
registered++;
|
|
2901
|
+
}
|
|
2902
|
+
for (const uc of model.uniqueConstraints || []) {
|
|
2903
|
+
if (existingConstraintSet.has(uc.name)) continue;
|
|
2904
|
+
this._engine.registerUniqueConstraint(model.modelName, uc.name, uc.fields);
|
|
2905
|
+
registered++;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
const response = { registered };
|
|
2909
|
+
return Response.json(response);
|
|
2910
|
+
}
|
|
2911
|
+
/** @internal */
|
|
2912
|
+
async _handleIndexRegister(request) {
|
|
2913
|
+
const body = await request.json();
|
|
2914
|
+
const { modelName, fieldName, fieldType, unique } = body;
|
|
2915
|
+
if (!modelName || !fieldName || !fieldType) {
|
|
2916
|
+
return this._errorResponse(
|
|
2917
|
+
"modelName, fieldName, and fieldType are required",
|
|
2918
|
+
400
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
this._engine.registerIndex(
|
|
2922
|
+
modelName,
|
|
2923
|
+
fieldName,
|
|
2924
|
+
fieldType,
|
|
2925
|
+
unique || false
|
|
2926
|
+
);
|
|
2927
|
+
const response = {
|
|
2928
|
+
success: true,
|
|
2929
|
+
modelName,
|
|
2930
|
+
fieldName
|
|
2931
|
+
};
|
|
2932
|
+
return Response.json(response);
|
|
2933
|
+
}
|
|
2934
|
+
/** @internal */
|
|
2935
|
+
async _handleIndexDrop(request) {
|
|
2936
|
+
const body = await request.json();
|
|
2937
|
+
const { modelName, fieldName } = body;
|
|
2938
|
+
if (!modelName || !fieldName) {
|
|
2939
|
+
return this._errorResponse(
|
|
2940
|
+
"modelName and fieldName are required",
|
|
2941
|
+
400
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
this._engine.dropIndex(modelName, fieldName);
|
|
2945
|
+
const response = {
|
|
2946
|
+
success: true,
|
|
2947
|
+
modelName,
|
|
2948
|
+
fieldName
|
|
2949
|
+
};
|
|
2950
|
+
return Response.json(response);
|
|
2951
|
+
}
|
|
2952
|
+
/** @internal */
|
|
2953
|
+
_handleIndexList(request) {
|
|
2954
|
+
const url = new URL(request.url);
|
|
2955
|
+
const modelName = url.searchParams.get("modelName") || void 0;
|
|
2956
|
+
const indexes = this._engine.listIndexes(modelName);
|
|
2957
|
+
const response = { indexes };
|
|
2958
|
+
return Response.json(response);
|
|
2959
|
+
}
|
|
2960
|
+
/** @internal */
|
|
2961
|
+
async _handleUniqueConstraintRegister(request) {
|
|
2962
|
+
const body = await request.json();
|
|
2963
|
+
const { modelName, constraintName, fields } = body;
|
|
2964
|
+
if (!modelName || !constraintName || !fields || !Array.isArray(fields) || fields.length < 2) {
|
|
2965
|
+
return this._errorResponse(
|
|
2966
|
+
"modelName, constraintName, and fields (array with 2+ fields) are required",
|
|
2967
|
+
400
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
this._engine.registerUniqueConstraint(modelName, constraintName, fields);
|
|
2971
|
+
const response = {
|
|
2972
|
+
success: true,
|
|
2973
|
+
modelName,
|
|
2974
|
+
constraintName
|
|
2975
|
+
};
|
|
2976
|
+
return Response.json(response);
|
|
2977
|
+
}
|
|
2978
|
+
/** @internal */
|
|
2979
|
+
async _handleUniqueConstraintDrop(request) {
|
|
2980
|
+
const body = await request.json();
|
|
2981
|
+
const { modelName, constraintName } = body;
|
|
2982
|
+
if (!modelName || !constraintName) {
|
|
2983
|
+
return this._errorResponse(
|
|
2984
|
+
"modelName and constraintName are required",
|
|
2985
|
+
400
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2988
|
+
this._engine.dropUniqueConstraint(modelName, constraintName);
|
|
2989
|
+
const response = {
|
|
2990
|
+
success: true,
|
|
2991
|
+
modelName,
|
|
2992
|
+
constraintName
|
|
2993
|
+
};
|
|
2994
|
+
return Response.json(response);
|
|
2995
|
+
}
|
|
2996
|
+
/** @internal */
|
|
2997
|
+
_handleUniqueConstraintList(request) {
|
|
2998
|
+
const url = new URL(request.url);
|
|
2999
|
+
const modelName = url.searchParams.get("modelName") || void 0;
|
|
3000
|
+
const constraints = this._engine.listUniqueConstraints(modelName);
|
|
3001
|
+
const response = { constraints };
|
|
3002
|
+
return Response.json(response);
|
|
3003
|
+
}
|
|
3004
|
+
/** @internal */
|
|
3005
|
+
/**
|
|
3006
|
+
* Extract field names that use $contains from a filter tree.
|
|
3007
|
+
* @internal
|
|
3008
|
+
*/
|
|
3009
|
+
_findContainsFields(filter) {
|
|
3010
|
+
const fields = [];
|
|
3011
|
+
if (!filter || typeof filter !== "object") return fields;
|
|
3012
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
3013
|
+
if (key === "$and" || key === "$or") {
|
|
3014
|
+
if (Array.isArray(value)) {
|
|
3015
|
+
for (const sub of value) {
|
|
3016
|
+
fields.push(...this._findContainsFields(sub));
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
} else if (value && typeof value === "object" && !Array.isArray(value) && "$contains" in value) {
|
|
3020
|
+
fields.push(key);
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
return fields;
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Check for $contains misuse with caching.
|
|
3027
|
+
* Returns an error Response if misuse is detected, or null if OK.
|
|
3028
|
+
* @internal
|
|
3029
|
+
*/
|
|
3030
|
+
_checkContainsMisuse(modelName, filter) {
|
|
3031
|
+
const containsFields = this._findContainsFields(filter);
|
|
3032
|
+
for (const field of containsFields) {
|
|
3033
|
+
assertValidIdentifier(field, "$contains field");
|
|
3034
|
+
const cacheKey = `${modelName}:${field}`;
|
|
3035
|
+
if (this._containsMisuseCache.has(cacheKey)) {
|
|
3036
|
+
return this._errorResponse(
|
|
3037
|
+
`Field '${field}' was saved as regular data, not a StringSet. To use $contains, save the field via stringSets instead of data.`,
|
|
3038
|
+
400
|
|
3039
|
+
);
|
|
3040
|
+
}
|
|
3041
|
+
const check = this._engine.execSqlSync(
|
|
3042
|
+
`SELECT 1 FROM records WHERE type = ? AND json_type(data_json, '$.${field}') IS NOT NULL AND json_type(data_json, '$.${field}') != 'array' LIMIT 1`,
|
|
3043
|
+
[modelName]
|
|
3044
|
+
);
|
|
3045
|
+
if (check.length > 0) {
|
|
3046
|
+
this._containsMisuseCache.add(cacheKey);
|
|
3047
|
+
return this._errorResponse(
|
|
3048
|
+
`Field '${field}' was saved as regular data, not a StringSet. To use $contains, save the field via stringSets instead of data.`,
|
|
3049
|
+
400
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
return null;
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Check a write condition against the current state of a record.
|
|
3057
|
+
* Returns true if the condition is met (record exists and matches the filter).
|
|
3058
|
+
* Returns false if the record doesn't exist or doesn't match.
|
|
3059
|
+
* @internal
|
|
3060
|
+
*/
|
|
3061
|
+
_checkCondition(modelName, id, condition) {
|
|
3062
|
+
const translator = new JsonQueryTranslator(modelName, void 0, {
|
|
3063
|
+
includeDocId: false
|
|
3064
|
+
});
|
|
3065
|
+
const whereClause = translator.translateFilter(condition);
|
|
3066
|
+
let sql = "SELECT 1 FROM records WHERE id = ? AND type = ?";
|
|
3067
|
+
const params = [id, modelName];
|
|
3068
|
+
if (whereClause.sql) {
|
|
3069
|
+
sql += ` AND ${whereClause.sql}`;
|
|
3070
|
+
params.push(...whereClause.params);
|
|
3071
|
+
}
|
|
3072
|
+
const rows = this._engine.execSqlSync(sql, params);
|
|
3073
|
+
return rows.length > 0;
|
|
3074
|
+
}
|
|
3075
|
+
_handleHealth() {
|
|
3076
|
+
return Response.json({ status: "ok" });
|
|
3077
|
+
}
|
|
3078
|
+
/** @internal — Return tracked field names and types for a model */
|
|
3079
|
+
_handleDescribe(request) {
|
|
3080
|
+
const url = new URL(request.url);
|
|
3081
|
+
const modelName = url.searchParams.get("modelName") || void 0;
|
|
3082
|
+
if (!modelName) {
|
|
3083
|
+
const body = { error: "modelName query parameter is required" };
|
|
3084
|
+
return Response.json(body, { status: 400 });
|
|
3085
|
+
}
|
|
3086
|
+
const fields = this._engine.getModelFields(modelName);
|
|
3087
|
+
const response = { modelName, fields };
|
|
3088
|
+
return Response.json(response);
|
|
3089
|
+
}
|
|
3090
|
+
/** @internal */
|
|
3091
|
+
_errorResponse(message, status) {
|
|
3092
|
+
const body = { error: message };
|
|
3093
|
+
return Response.json(body, { status });
|
|
3094
|
+
}
|
|
3095
|
+
/** @internal */
|
|
3096
|
+
_parseRow(row) {
|
|
3097
|
+
const { id, type, data_json, ...rest } = row;
|
|
3098
|
+
let parsed = {};
|
|
3099
|
+
if (data_json) {
|
|
3100
|
+
try {
|
|
3101
|
+
parsed = JSON.parse(data_json);
|
|
3102
|
+
} catch (e) {
|
|
3103
|
+
console.warn("Failed to parse data_json:", e);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
return { id, type, ...parsed, ...rest };
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
var createDocumentDO = createDatabaseDO;
|
|
3111
|
+
|
|
3112
|
+
// src/engines/cloudflare/worker.template.ts
|
|
3113
|
+
var CORS_HEADERS = {
|
|
3114
|
+
"Access-Control-Allow-Origin": "*",
|
|
3115
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
3116
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
3117
|
+
};
|
|
3118
|
+
function handleOptions() {
|
|
3119
|
+
return new Response(null, {
|
|
3120
|
+
status: 204,
|
|
3121
|
+
headers: CORS_HEADERS
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
function addCorsHeaders(response) {
|
|
3125
|
+
const newHeaders = new Headers(response.headers);
|
|
3126
|
+
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
|
3127
|
+
newHeaders.set(key, value);
|
|
3128
|
+
}
|
|
3129
|
+
return new Response(response.body, {
|
|
3130
|
+
status: response.status,
|
|
3131
|
+
statusText: response.statusText,
|
|
3132
|
+
headers: newHeaders
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
function getDocumentStub(env, docId) {
|
|
3136
|
+
const doId = env.DOCUMENT_DO.idFromName(docId);
|
|
3137
|
+
return env.DOCUMENT_DO.get(doId);
|
|
3138
|
+
}
|
|
3139
|
+
async function handleRequest(request, env) {
|
|
3140
|
+
if (request.method === "OPTIONS") {
|
|
3141
|
+
return handleOptions();
|
|
3142
|
+
}
|
|
3143
|
+
const url = new URL(request.url);
|
|
3144
|
+
let docId = url.searchParams.get("docId");
|
|
3145
|
+
if (!docId) {
|
|
3146
|
+
const pathMatch = url.pathname.match(/^\/docs\/([^\/]+)\/(.+)$/);
|
|
3147
|
+
if (pathMatch) {
|
|
3148
|
+
docId = pathMatch[1];
|
|
3149
|
+
url.pathname = "/" + pathMatch[2];
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
if (!docId) {
|
|
3153
|
+
return addCorsHeaders(
|
|
3154
|
+
Response.json(
|
|
3155
|
+
{ error: "Missing docId parameter" },
|
|
3156
|
+
{ status: 400 }
|
|
3157
|
+
)
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
const stub = getDocumentStub(env, docId);
|
|
3161
|
+
const doRequest = new Request(url.toString(), {
|
|
3162
|
+
method: request.method,
|
|
3163
|
+
headers: request.headers,
|
|
3164
|
+
body: request.body
|
|
3165
|
+
});
|
|
3166
|
+
const response = await stub.fetch(doRequest);
|
|
3167
|
+
return addCorsHeaders(response);
|
|
3168
|
+
}
|