js-bao 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/dist/BaseModel-5YQCROYE.js +17 -0
- package/dist/BaseModel-5YQCROYE.js.map +1 -0
- package/dist/BaseModel-FCNWDJBH.js +17 -0
- package/dist/BaseModel-FCNWDJBH.js.map +1 -0
- package/dist/BrowserDatabaseFactory-PXOTK2DQ.js +119 -0
- package/dist/BrowserDatabaseFactory-PXOTK2DQ.js.map +1 -0
- package/dist/BrowserDatabaseFactory-WD4VX2VZ.js +119 -0
- package/dist/BrowserDatabaseFactory-WD4VX2VZ.js.map +1 -0
- package/dist/IncludeResolver-RCKQGNPZ.js +385 -0
- package/dist/IncludeResolver-RCKQGNPZ.js.map +1 -0
- package/dist/IncludeResolver-WGSQDMS7.js +385 -0
- package/dist/IncludeResolver-WGSQDMS7.js.map +1 -0
- package/dist/NodeDatabaseFactory-J4Z36UF3.js +165 -0
- package/dist/NodeDatabaseFactory-J4Z36UF3.js.map +1 -0
- package/dist/NodeDatabaseFactory-QIEKAXBM.js +10 -0
- package/dist/NodeDatabaseFactory-QIEKAXBM.js.map +1 -0
- package/dist/NodeSqliteEngine-HJSAYE4E.js +383 -0
- package/dist/NodeSqliteEngine-HJSAYE4E.js.map +1 -0
- package/dist/NodeSqliteEngine-I5SLWLME.js +383 -0
- package/dist/NodeSqliteEngine-I5SLWLME.js.map +1 -0
- package/dist/browser.cjs +3779 -3370
- package/dist/browser.d.cts +18 -1
- package/dist/browser.d.ts +18 -1
- package/dist/browser.js +3750 -3341
- package/dist/chunk-3PZWHUZO.js +4153 -0
- package/dist/chunk-3PZWHUZO.js.map +1 -0
- package/dist/chunk-53MS4MN7.js +373 -0
- package/dist/chunk-53MS4MN7.js.map +1 -0
- package/dist/chunk-65G2P4GL.js +709 -0
- package/dist/chunk-65G2P4GL.js.map +1 -0
- package/dist/chunk-6UX3YSCW.js +4151 -0
- package/dist/chunk-6UX3YSCW.js.map +1 -0
- package/dist/chunk-DANSD6BE.js +709 -0
- package/dist/chunk-DANSD6BE.js.map +1 -0
- package/dist/chunk-DF3JEQXA.js +373 -0
- package/dist/chunk-DF3JEQXA.js.map +1 -0
- package/dist/chunk-GO3APTPX.js +61 -0
- package/dist/chunk-GO3APTPX.js.map +1 -0
- package/dist/chunk-ID4U6IQC.js +53 -0
- package/dist/chunk-ID4U6IQC.js.map +1 -0
- package/dist/chunk-RQVS3LVL.js +165 -0
- package/dist/chunk-RQVS3LVL.js.map +1 -0
- package/dist/client.cjs +837 -0
- package/dist/client.d.cts +1101 -0
- package/dist/client.d.ts +1101 -0
- package/dist/client.js +806 -0
- package/dist/cloudflare-do.cjs +3637 -0
- package/dist/cloudflare-do.d.cts +1366 -0
- package/dist/cloudflare-do.d.ts +1366 -0
- package/dist/cloudflare-do.js +3614 -0
- package/dist/cloudflare.cjs +1048 -0
- package/dist/cloudflare.d.cts +1381 -0
- package/dist/cloudflare.d.ts +1381 -0
- package/dist/cloudflare.js +1017 -0
- package/dist/codegen.cjs +260 -19
- package/dist/environment-TOTQICSE.js +17 -0
- package/dist/environment-TOTQICSE.js.map +1 -0
- package/dist/index.cjs +1905 -1492
- package/dist/index.d.cts +19 -2
- package/dist/index.d.ts +19 -2
- package/dist/index.js +1870 -1457
- package/dist/node.cjs +4779 -4366
- package/dist/node.d.cts +18 -1
- package/dist/node.d.ts +18 -1
- package/dist/node.js +4758 -4345
- package/package.json +42 -13
|
@@ -0,0 +1,4151 @@
|
|
|
1
|
+
// src/models/BaseModel.ts
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { ulid } from "ulid";
|
|
4
|
+
|
|
5
|
+
// src/models/StringSet.ts
|
|
6
|
+
var StringSet = class _StringSet {
|
|
7
|
+
_values;
|
|
8
|
+
_model;
|
|
9
|
+
_fieldName;
|
|
10
|
+
constructor(model, fieldName, initialValues = []) {
|
|
11
|
+
this._model = model;
|
|
12
|
+
this._fieldName = fieldName;
|
|
13
|
+
this._values = new Set(initialValues);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Add a string to the set
|
|
17
|
+
*/
|
|
18
|
+
add(value) {
|
|
19
|
+
if (typeof value !== "string") {
|
|
20
|
+
throw new Error("StringSet can only contain string values");
|
|
21
|
+
}
|
|
22
|
+
if (!this._values.has(value)) {
|
|
23
|
+
this._values.add(value);
|
|
24
|
+
this._model.markStringSetChange(this._fieldName, "add", value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Remove a string from the set
|
|
29
|
+
*/
|
|
30
|
+
remove(value) {
|
|
31
|
+
if (this._values.has(value)) {
|
|
32
|
+
this._values.delete(value);
|
|
33
|
+
this._model.markStringSetChange(this._fieldName, "remove", value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check if the set contains a string
|
|
38
|
+
*/
|
|
39
|
+
has(value) {
|
|
40
|
+
return this._values.has(value);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Clear all strings from the set
|
|
44
|
+
*/
|
|
45
|
+
clear() {
|
|
46
|
+
if (this._values.size > 0) {
|
|
47
|
+
this._values.clear();
|
|
48
|
+
this._model.markStringSetChange(this._fieldName, "clear");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get the number of strings in the set
|
|
53
|
+
*/
|
|
54
|
+
get size() {
|
|
55
|
+
return this._values.size;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get an iterator of all values
|
|
59
|
+
*/
|
|
60
|
+
values() {
|
|
61
|
+
return this._values.values();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Make the StringSet iterable
|
|
65
|
+
*/
|
|
66
|
+
[Symbol.iterator]() {
|
|
67
|
+
return this._values[Symbol.iterator]();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert to array
|
|
71
|
+
*/
|
|
72
|
+
toArray() {
|
|
73
|
+
return Array.from(this._values);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Union with another StringSet
|
|
77
|
+
*/
|
|
78
|
+
union(other) {
|
|
79
|
+
const result = new _StringSet(this._model, this._fieldName, this.toArray());
|
|
80
|
+
for (const value of other) {
|
|
81
|
+
result._values.add(value);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Intersection with another StringSet
|
|
87
|
+
*/
|
|
88
|
+
intersection(other) {
|
|
89
|
+
const result = new _StringSet(this._model, this._fieldName);
|
|
90
|
+
for (const value of this._values) {
|
|
91
|
+
if (other.has(value)) {
|
|
92
|
+
result._values.add(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Difference with another StringSet (values in this set but not in other)
|
|
99
|
+
*/
|
|
100
|
+
difference(other) {
|
|
101
|
+
const result = new _StringSet(this._model, this._fieldName);
|
|
102
|
+
for (const value of this._values) {
|
|
103
|
+
if (!other.has(value)) {
|
|
104
|
+
result._values.add(value);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get the current state including pending changes
|
|
111
|
+
* This is used internally by the model to determine the current view
|
|
112
|
+
*/
|
|
113
|
+
_getCurrentState() {
|
|
114
|
+
return new Set(this._values);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Update the internal state (used when loading from Yjs or applying changes)
|
|
118
|
+
*/
|
|
119
|
+
_updateInternalState(values) {
|
|
120
|
+
this._values = new Set(values);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// src/types/documentTypes.ts
|
|
125
|
+
var DocumentClosedError = class extends Error {
|
|
126
|
+
code = "ERR_DOC_CLOSED";
|
|
127
|
+
constructor(message) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.name = "DocumentClosedError";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
var DocumentResolutionError = class extends Error {
|
|
133
|
+
code = "ERR_DOC_UNRESOLVED";
|
|
134
|
+
constructor(message) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = "DocumentResolutionError";
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/types/queryTypes.ts
|
|
141
|
+
var DocumentQueryError = class _DocumentQueryError extends Error {
|
|
142
|
+
constructor(message) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "DocumentQueryError";
|
|
145
|
+
Object.setPrototypeOf(this, _DocumentQueryError.prototype);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var InvalidOperatorError = class _InvalidOperatorError extends DocumentQueryError {
|
|
149
|
+
field;
|
|
150
|
+
operator;
|
|
151
|
+
fieldType;
|
|
152
|
+
constructor(message, field, operator, fieldType) {
|
|
153
|
+
super(message);
|
|
154
|
+
this.name = "InvalidOperatorError";
|
|
155
|
+
this.field = field;
|
|
156
|
+
this.operator = operator;
|
|
157
|
+
this.fieldType = fieldType;
|
|
158
|
+
Object.setPrototypeOf(this, _InvalidOperatorError.prototype);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
var InvalidFieldError = class _InvalidFieldError extends DocumentQueryError {
|
|
162
|
+
field;
|
|
163
|
+
modelName;
|
|
164
|
+
constructor(message, field, modelName) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = "InvalidFieldError";
|
|
167
|
+
this.field = field;
|
|
168
|
+
this.modelName = modelName;
|
|
169
|
+
Object.setPrototypeOf(this, _InvalidFieldError.prototype);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var InvalidCursorError = class _InvalidCursorError extends DocumentQueryError {
|
|
173
|
+
cursor;
|
|
174
|
+
constructor(message, cursor) {
|
|
175
|
+
super(message);
|
|
176
|
+
this.name = "InvalidCursorError";
|
|
177
|
+
this.cursor = cursor;
|
|
178
|
+
Object.setPrototypeOf(this, _InvalidCursorError.prototype);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// src/query/CursorManager.ts
|
|
183
|
+
var base64Encode = (str) => {
|
|
184
|
+
if (typeof btoa !== "undefined") {
|
|
185
|
+
return btoa(str);
|
|
186
|
+
} else if (typeof Buffer !== "undefined") {
|
|
187
|
+
return Buffer.from(str, "utf-8").toString("base64");
|
|
188
|
+
} else {
|
|
189
|
+
throw new Error("No base64 encoding available");
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
var base64Decode = (str) => {
|
|
193
|
+
if (typeof atob !== "undefined") {
|
|
194
|
+
return atob(str);
|
|
195
|
+
} else if (typeof Buffer !== "undefined") {
|
|
196
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
197
|
+
} else {
|
|
198
|
+
throw new Error("No base64 decoding available");
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
var CursorManager = class {
|
|
202
|
+
/**
|
|
203
|
+
* Encode cursor data to base64 string
|
|
204
|
+
*/
|
|
205
|
+
static encodeCursor(cursorData) {
|
|
206
|
+
try {
|
|
207
|
+
const jsonString = JSON.stringify(cursorData);
|
|
208
|
+
return base64Encode(jsonString);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new InvalidCursorError(
|
|
211
|
+
`Failed to encode cursor: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
212
|
+
JSON.stringify(cursorData)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Decode base64 cursor string to cursor data
|
|
218
|
+
*/
|
|
219
|
+
static decodeCursor(cursor) {
|
|
220
|
+
try {
|
|
221
|
+
const jsonString = base64Decode(cursor);
|
|
222
|
+
const parsed = JSON.parse(jsonString);
|
|
223
|
+
if (!parsed || typeof parsed !== "object") {
|
|
224
|
+
throw new Error("Cursor must be an object");
|
|
225
|
+
}
|
|
226
|
+
if (!parsed.values || typeof parsed.values !== "object") {
|
|
227
|
+
throw new Error("Cursor must have values object");
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(parsed.sortFields)) {
|
|
230
|
+
throw new Error("Cursor must have sortFields array");
|
|
231
|
+
}
|
|
232
|
+
if (parsed.direction !== 1 && parsed.direction !== -1) {
|
|
233
|
+
throw new Error("Cursor direction must be 1 or -1");
|
|
234
|
+
}
|
|
235
|
+
return parsed;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new InvalidCursorError(
|
|
238
|
+
`Failed to decode cursor: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
239
|
+
cursor
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Generate cursor from a record and sort specification
|
|
245
|
+
*/
|
|
246
|
+
static generateCursor(record, sortFields, direction) {
|
|
247
|
+
const values = {};
|
|
248
|
+
for (const field of sortFields) {
|
|
249
|
+
if (record.hasOwnProperty(field)) {
|
|
250
|
+
values[field] = record[field];
|
|
251
|
+
} else {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Cannot generate cursor: record missing sort field '${field}'`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const cursorData = {
|
|
258
|
+
values,
|
|
259
|
+
sortFields,
|
|
260
|
+
direction
|
|
261
|
+
};
|
|
262
|
+
return this.encodeCursor(cursorData);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Build SQL pagination conditions based on cursor
|
|
266
|
+
* Uses lexicographic ordering for stable pagination with multiple sort fields
|
|
267
|
+
*/
|
|
268
|
+
static buildPaginationConditions(cursor, currentSortFields, sortDirections, requestedDirection, fieldFormatter = (field) => field) {
|
|
269
|
+
if (!this.arraysEqual(cursor.sortFields, currentSortFields)) {
|
|
270
|
+
throw new InvalidCursorError(
|
|
271
|
+
`Cursor sort fields [${cursor.sortFields.join(
|
|
272
|
+
", "
|
|
273
|
+
)}] don't match query sort fields [${currentSortFields.join(", ")}]`,
|
|
274
|
+
JSON.stringify(cursor)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const conditions = [];
|
|
278
|
+
const params = [];
|
|
279
|
+
const sortFields = cursor.sortFields;
|
|
280
|
+
const direction = requestedDirection;
|
|
281
|
+
for (let i = 0; i < sortFields.length; i++) {
|
|
282
|
+
const fieldConditions = [];
|
|
283
|
+
const fieldParams = [];
|
|
284
|
+
for (let j = 0; j < i; j++) {
|
|
285
|
+
const field = sortFields[j];
|
|
286
|
+
const value = cursor.values[field];
|
|
287
|
+
fieldConditions.push(`${fieldFormatter(field)} = ?`);
|
|
288
|
+
fieldParams.push(value);
|
|
289
|
+
}
|
|
290
|
+
const currentField = sortFields[i];
|
|
291
|
+
const currentValue = cursor.values[currentField];
|
|
292
|
+
const fieldSortDir = sortDirections[i] ?? 1;
|
|
293
|
+
const forwardOp = fieldSortDir === 1 ? ">" : "<";
|
|
294
|
+
const operator = direction === 1 ? forwardOp : forwardOp === ">" ? "<" : ">";
|
|
295
|
+
fieldConditions.push(`${fieldFormatter(currentField)} ${operator} ?`);
|
|
296
|
+
fieldParams.push(currentValue);
|
|
297
|
+
const levelCondition = fieldConditions.join(" AND ");
|
|
298
|
+
conditions.push(`(${levelCondition})`);
|
|
299
|
+
params.push(...fieldParams);
|
|
300
|
+
}
|
|
301
|
+
const sql = `(${conditions.join(" OR ")})`;
|
|
302
|
+
return { sql, params };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Extract sort fields from sort specification, ensuring 'id' is always included for stability
|
|
306
|
+
*/
|
|
307
|
+
static extractSortFields(sort) {
|
|
308
|
+
const fields = [];
|
|
309
|
+
if (sort && Object.keys(sort).length > 0) {
|
|
310
|
+
fields.push(...Object.keys(sort));
|
|
311
|
+
}
|
|
312
|
+
if (!fields.includes("id")) {
|
|
313
|
+
fields.push("id");
|
|
314
|
+
}
|
|
315
|
+
return fields;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Build ORDER BY clause from sort specification
|
|
319
|
+
*/
|
|
320
|
+
static buildOrderClause(sort, fieldFormatter = (field) => field) {
|
|
321
|
+
const fields = this.extractSortFields(sort);
|
|
322
|
+
const clauses = [];
|
|
323
|
+
const directions = [];
|
|
324
|
+
if (sort && Object.keys(sort).length > 0) {
|
|
325
|
+
for (const [field, dir] of Object.entries(sort)) {
|
|
326
|
+
const sqlDirection = dir === 1 ? "ASC" : "DESC";
|
|
327
|
+
clauses.push(`${fieldFormatter(field)} ${sqlDirection}`);
|
|
328
|
+
directions.push(dir);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!sort || !sort.hasOwnProperty("id")) {
|
|
332
|
+
clauses.push(`${fieldFormatter("id")} ASC`);
|
|
333
|
+
directions.push(1);
|
|
334
|
+
} else if (sort && sort.hasOwnProperty("id")) {
|
|
335
|
+
directions.push(sort["id"]);
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
sql: clauses.join(", "),
|
|
339
|
+
fields,
|
|
340
|
+
directions
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Determine if there are more results available for pagination
|
|
345
|
+
*/
|
|
346
|
+
static hasMoreResults(requestedLimit, actualResultCount) {
|
|
347
|
+
if (!requestedLimit || requestedLimit <= 0) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
return actualResultCount >= requestedLimit;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Generate next and previous cursors from result set
|
|
354
|
+
*/
|
|
355
|
+
static generateResultCursors(results, sortFields, _requestedDirection, hasMore, isFirstPage = false) {
|
|
356
|
+
if (results.length === 0) {
|
|
357
|
+
return {};
|
|
358
|
+
}
|
|
359
|
+
const cursors = {};
|
|
360
|
+
if (hasMore && results.length > 0) {
|
|
361
|
+
const lastResult = results[results.length - 1];
|
|
362
|
+
cursors.nextCursor = this.generateCursor(lastResult, sortFields, 1);
|
|
363
|
+
}
|
|
364
|
+
if (results.length > 0 && !isFirstPage) {
|
|
365
|
+
const firstResult = results[0];
|
|
366
|
+
cursors.prevCursor = this.generateCursor(firstResult, sortFields, -1);
|
|
367
|
+
}
|
|
368
|
+
return cursors;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Utility to compare arrays for equality
|
|
372
|
+
*/
|
|
373
|
+
static arraysEqual(a, b) {
|
|
374
|
+
if (a.length !== b.length) return false;
|
|
375
|
+
return a.every((val, index) => val === b[index]);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// src/utils/sql.ts
|
|
380
|
+
var IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
381
|
+
function isValidIdentifier(name) {
|
|
382
|
+
return IDENTIFIER_PATTERN.test(name);
|
|
383
|
+
}
|
|
384
|
+
function assertValidIdentifier(name, context) {
|
|
385
|
+
if (!isValidIdentifier(name)) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`${context}: Identifier "${name}" must match ${IDENTIFIER_PATTERN.source}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function quoteIdentifier(name) {
|
|
392
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
393
|
+
}
|
|
394
|
+
function deterministicIdentifierHash(value) {
|
|
395
|
+
let hash = 0;
|
|
396
|
+
for (let i = 0; i < value.length; i++) {
|
|
397
|
+
hash = (hash << 5) - hash + value.charCodeAt(i);
|
|
398
|
+
hash |= 0;
|
|
399
|
+
}
|
|
400
|
+
const positiveHash = hash === 0 ? 0 : Math.abs(hash);
|
|
401
|
+
return positiveHash.toString(36);
|
|
402
|
+
}
|
|
403
|
+
function buildSafeAlias(prefix, rawDescriptor) {
|
|
404
|
+
const suffix = deterministicIdentifierHash(rawDescriptor);
|
|
405
|
+
const candidate = `${prefix}_${suffix}`;
|
|
406
|
+
assertValidIdentifier(candidate, "Alias generation");
|
|
407
|
+
return candidate;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/utils/patterns.ts
|
|
411
|
+
function escapeLikeLiteral(input) {
|
|
412
|
+
return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
413
|
+
}
|
|
414
|
+
function buildLikePattern(value, mode) {
|
|
415
|
+
const trimmed = (value ?? "").trim();
|
|
416
|
+
if (trimmed.length === 0) return null;
|
|
417
|
+
if (trimmed.length > 1024) {
|
|
418
|
+
throw new Error("substring value exceeds 1024 characters");
|
|
419
|
+
}
|
|
420
|
+
const escaped = escapeLikeLiteral(trimmed);
|
|
421
|
+
switch (mode) {
|
|
422
|
+
case "startsWith":
|
|
423
|
+
return `${escaped}%`;
|
|
424
|
+
case "endsWith":
|
|
425
|
+
return `%${escaped}`;
|
|
426
|
+
case "containsText":
|
|
427
|
+
return `%${escaped}%`;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function shouldLogLikeEscapes() {
|
|
431
|
+
try {
|
|
432
|
+
if (typeof process !== "undefined" && process.env) {
|
|
433
|
+
return !!process.env.JS_BAO_DEBUG_LIKE_ESCAPES;
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/query/DocumentQueryTranslator.ts
|
|
441
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["id", "type"]);
|
|
442
|
+
var DocumentQueryTranslator = class {
|
|
443
|
+
modelName;
|
|
444
|
+
schema;
|
|
445
|
+
tableName;
|
|
446
|
+
quotedTableName;
|
|
447
|
+
fieldSqlCache;
|
|
448
|
+
constructor(modelName, schema) {
|
|
449
|
+
this.modelName = modelName;
|
|
450
|
+
this.schema = schema;
|
|
451
|
+
this.tableName = `model_${modelName.toLowerCase()}`;
|
|
452
|
+
this.quotedTableName = quoteIdentifier(this.tableName);
|
|
453
|
+
this.fieldSqlCache = /* @__PURE__ */ new Map();
|
|
454
|
+
}
|
|
455
|
+
getQuotedField(fieldName) {
|
|
456
|
+
if (!this.fieldSqlCache.has(fieldName)) {
|
|
457
|
+
if (!this.schema.has(fieldName) && !SYSTEM_FIELDS.has(fieldName)) {
|
|
458
|
+
throw new InvalidFieldError(
|
|
459
|
+
`Unknown field: ${fieldName} in model ${this.modelName}`,
|
|
460
|
+
fieldName,
|
|
461
|
+
this.modelName
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
this.fieldSqlCache.set(fieldName, quoteIdentifier(fieldName));
|
|
465
|
+
}
|
|
466
|
+
return this.fieldSqlCache.get(fieldName);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Translate document filter and options to SQL for find operations
|
|
470
|
+
*/
|
|
471
|
+
translateFind(filter, options) {
|
|
472
|
+
if (options?.projection) {
|
|
473
|
+
this.validateProjection(options.projection);
|
|
474
|
+
}
|
|
475
|
+
const whereClause = this.translateFilter(filter);
|
|
476
|
+
const orderClause = CursorManager.buildOrderClause(
|
|
477
|
+
options?.sort,
|
|
478
|
+
(field) => this.getQuotedField(field)
|
|
479
|
+
);
|
|
480
|
+
const limitClause = this.buildLimitClause(options);
|
|
481
|
+
const paginationClause = this.buildPaginationClause(
|
|
482
|
+
options,
|
|
483
|
+
orderClause.fields,
|
|
484
|
+
orderClause.directions
|
|
485
|
+
);
|
|
486
|
+
const selectClause = this.buildSelectClause(options?.projection);
|
|
487
|
+
let sql = `SELECT ${selectClause} FROM ${this.quotedTableName}`;
|
|
488
|
+
const params = [];
|
|
489
|
+
const conditions = [];
|
|
490
|
+
if (whereClause.sql) {
|
|
491
|
+
conditions.push(whereClause.sql);
|
|
492
|
+
params.push(...whereClause.params);
|
|
493
|
+
}
|
|
494
|
+
if (paginationClause.sql) {
|
|
495
|
+
conditions.push(paginationClause.sql);
|
|
496
|
+
params.push(...paginationClause.params);
|
|
497
|
+
}
|
|
498
|
+
const documentClause = this.buildDocumentClause(options?.documents);
|
|
499
|
+
if (documentClause) {
|
|
500
|
+
conditions.push(documentClause.sql);
|
|
501
|
+
params.push(...documentClause.params);
|
|
502
|
+
}
|
|
503
|
+
if (conditions.length > 0) {
|
|
504
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
505
|
+
}
|
|
506
|
+
if (orderClause.sql) {
|
|
507
|
+
sql += ` ORDER BY ${orderClause.sql}`;
|
|
508
|
+
}
|
|
509
|
+
if (limitClause.sql) {
|
|
510
|
+
sql += ` LIMIT ${limitClause.sql}`;
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
sql,
|
|
514
|
+
params,
|
|
515
|
+
sortFields: orderClause.fields,
|
|
516
|
+
sortDirections: orderClause.directions
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Translate document filter to SQL for count operations
|
|
521
|
+
*/
|
|
522
|
+
translateCount(filter, options) {
|
|
523
|
+
const whereClause = this.translateFilter(filter);
|
|
524
|
+
const documentClause = this.buildDocumentClause(options?.documents);
|
|
525
|
+
const conditions = [];
|
|
526
|
+
const params = [];
|
|
527
|
+
if (whereClause.sql) {
|
|
528
|
+
conditions.push(whereClause.sql);
|
|
529
|
+
params.push(...whereClause.params);
|
|
530
|
+
}
|
|
531
|
+
if (documentClause) {
|
|
532
|
+
conditions.push(documentClause.sql);
|
|
533
|
+
params.push(...documentClause.params);
|
|
534
|
+
}
|
|
535
|
+
let sql = `SELECT COUNT(*) as count FROM ${this.quotedTableName}`;
|
|
536
|
+
if (conditions.length > 0) {
|
|
537
|
+
sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
sql,
|
|
541
|
+
params
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Translate document filter to SQL WHERE clause
|
|
546
|
+
*/
|
|
547
|
+
translateFilter(filter) {
|
|
548
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
549
|
+
return { sql: "", params: [] };
|
|
550
|
+
}
|
|
551
|
+
const conditions = [];
|
|
552
|
+
const params = [];
|
|
553
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
554
|
+
if (key === "$and") {
|
|
555
|
+
const andClause = this.translateLogicalOperator(
|
|
556
|
+
"AND",
|
|
557
|
+
value
|
|
558
|
+
);
|
|
559
|
+
if (andClause.sql) {
|
|
560
|
+
conditions.push(`(${andClause.sql})`);
|
|
561
|
+
params.push(...andClause.params);
|
|
562
|
+
}
|
|
563
|
+
} else if (key === "$or") {
|
|
564
|
+
const orClause = this.translateLogicalOperator(
|
|
565
|
+
"OR",
|
|
566
|
+
value
|
|
567
|
+
);
|
|
568
|
+
if (orClause.sql) {
|
|
569
|
+
conditions.push(`(${orClause.sql})`);
|
|
570
|
+
params.push(...orClause.params);
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
const fieldClause = this.translateFieldCondition(key, value);
|
|
574
|
+
if (fieldClause.sql) {
|
|
575
|
+
conditions.push(fieldClause.sql);
|
|
576
|
+
params.push(...fieldClause.params);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
sql: conditions.join(" AND "),
|
|
582
|
+
params
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Translate logical operators ($and, $or)
|
|
587
|
+
*/
|
|
588
|
+
translateLogicalOperator(operator, filters) {
|
|
589
|
+
if (!Array.isArray(filters) || filters.length === 0) {
|
|
590
|
+
return { sql: "", params: [] };
|
|
591
|
+
}
|
|
592
|
+
const clauses = [];
|
|
593
|
+
const params = [];
|
|
594
|
+
for (const filter of filters) {
|
|
595
|
+
const clause = this.translateFilter(filter);
|
|
596
|
+
if (clause.sql) {
|
|
597
|
+
clauses.push(clause.sql);
|
|
598
|
+
params.push(...clause.params);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (clauses.length === 0) {
|
|
602
|
+
return { sql: "", params: [] };
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
sql: clauses.join(` ${operator} `),
|
|
606
|
+
params
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Translate field condition to SQL
|
|
611
|
+
*/
|
|
612
|
+
translateFieldCondition(fieldName, condition) {
|
|
613
|
+
const column = this.getQuotedField(fieldName);
|
|
614
|
+
if (condition === null || condition === void 0) {
|
|
615
|
+
return { sql: `${column} IS NULL`, params: [] };
|
|
616
|
+
}
|
|
617
|
+
if (this.isPrimitiveValue(condition)) {
|
|
618
|
+
this.validateFieldValue(fieldName, condition);
|
|
619
|
+
return {
|
|
620
|
+
sql: `${column} = ?`,
|
|
621
|
+
params: [this.convertValueForSQLite(condition)]
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (typeof condition === "object" && !Array.isArray(condition)) {
|
|
625
|
+
return this.translateFieldOperators(fieldName, condition);
|
|
626
|
+
}
|
|
627
|
+
if (Array.isArray(condition)) {
|
|
628
|
+
this.validateArrayValues(fieldName, condition);
|
|
629
|
+
const placeholders = condition.map(() => "?").join(",");
|
|
630
|
+
const convertedValues = condition.map(
|
|
631
|
+
(v) => this.convertValueForSQLite(v)
|
|
632
|
+
);
|
|
633
|
+
return {
|
|
634
|
+
sql: `${column} IN (${placeholders})`,
|
|
635
|
+
params: convertedValues
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
throw new InvalidOperatorError(
|
|
639
|
+
`Unsupported condition type for field ${fieldName}`,
|
|
640
|
+
fieldName,
|
|
641
|
+
"unknown"
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Translate field operators to SQL
|
|
646
|
+
*/
|
|
647
|
+
translateFieldOperators(fieldName, operators) {
|
|
648
|
+
const conditions = [];
|
|
649
|
+
const params = [];
|
|
650
|
+
const fieldOptions = this.schema.get(fieldName);
|
|
651
|
+
const fieldType = fieldOptions?.type;
|
|
652
|
+
const column = this.getQuotedField(fieldName);
|
|
653
|
+
const substringOps = ["$startsWith", "$endsWith", "$containsText"];
|
|
654
|
+
const presentSubstringOps = substringOps.filter(
|
|
655
|
+
(op) => Object.prototype.hasOwnProperty.call(operators, op)
|
|
656
|
+
);
|
|
657
|
+
if (presentSubstringOps.length > 1) {
|
|
658
|
+
throw new InvalidOperatorError(
|
|
659
|
+
`Only one of $startsWith, $endsWith, $containsText may be used per field`,
|
|
660
|
+
fieldName,
|
|
661
|
+
presentSubstringOps[1],
|
|
662
|
+
fieldType
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (presentSubstringOps.length === 1) {
|
|
666
|
+
const op = presentSubstringOps[0];
|
|
667
|
+
const value = operators[op];
|
|
668
|
+
if (fieldType !== "string" && fieldType !== "stringset") {
|
|
669
|
+
throw new InvalidOperatorError(
|
|
670
|
+
`${op} operator is only supported for string and stringset fields, field ${fieldName} is type ${fieldType}`,
|
|
671
|
+
fieldName,
|
|
672
|
+
op,
|
|
673
|
+
fieldType
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (typeof value !== "string") {
|
|
677
|
+
throw new InvalidOperatorError(
|
|
678
|
+
`${op} operator requires a string value for field ${fieldName}`,
|
|
679
|
+
fieldName,
|
|
680
|
+
op,
|
|
681
|
+
fieldType
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
let pattern;
|
|
685
|
+
try {
|
|
686
|
+
const mode = op === "$startsWith" ? "startsWith" : op === "$endsWith" ? "endsWith" : "containsText";
|
|
687
|
+
pattern = buildLikePattern(value, mode);
|
|
688
|
+
} catch (e) {
|
|
689
|
+
throw new InvalidOperatorError(
|
|
690
|
+
e.message || "invalid substring value",
|
|
691
|
+
fieldName,
|
|
692
|
+
op,
|
|
693
|
+
fieldType
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (pattern !== null) {
|
|
697
|
+
if (shouldLogLikeEscapes()) {
|
|
698
|
+
try {
|
|
699
|
+
console.debug(
|
|
700
|
+
`[Substring] field=${fieldName} op=${op} pattern=${pattern} ci=true`
|
|
701
|
+
);
|
|
702
|
+
} catch {
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (fieldType === "string") {
|
|
706
|
+
const likeSql = `${column} LIKE ? ESCAPE '\\' COLLATE NOCASE`;
|
|
707
|
+
conditions.push(likeSql);
|
|
708
|
+
params.push(pattern);
|
|
709
|
+
} else {
|
|
710
|
+
const junctionTableName = `${this.tableName}_${fieldName}`;
|
|
711
|
+
const foreignKeyColumn = `${this.modelName.toLowerCase()}_id`;
|
|
712
|
+
const quotedJunctionTable = quoteIdentifier(junctionTableName);
|
|
713
|
+
const quotedForeignKey = quoteIdentifier(foreignKeyColumn);
|
|
714
|
+
const quotedValueColumn = quoteIdentifier("value");
|
|
715
|
+
const quotedPrimaryKey = quoteIdentifier("id");
|
|
716
|
+
const likeSql = `EXISTS (SELECT 1 FROM ${quotedJunctionTable} WHERE ${quotedJunctionTable}.${quotedForeignKey} = ${this.quotedTableName}.${quotedPrimaryKey} AND ${quotedJunctionTable}.${quotedValueColumn} LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
|
717
|
+
conditions.push(likeSql);
|
|
718
|
+
params.push(pattern);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
for (const [operator, value] of Object.entries(operators)) {
|
|
723
|
+
if (substringOps.includes(operator)) {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const opClause = this.translateOperator(fieldName, operator, value);
|
|
727
|
+
if (opClause.sql) {
|
|
728
|
+
conditions.push(opClause.sql);
|
|
729
|
+
params.push(...opClause.params);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
sql: conditions.join(" AND "),
|
|
734
|
+
params
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Translate individual operator to SQL
|
|
739
|
+
*/
|
|
740
|
+
translateOperator(fieldName, operator, value) {
|
|
741
|
+
const fieldOptions = this.schema.get(fieldName);
|
|
742
|
+
const fieldType = fieldOptions?.type;
|
|
743
|
+
const column = this.getQuotedField(fieldName);
|
|
744
|
+
switch (operator) {
|
|
745
|
+
case "$eq":
|
|
746
|
+
this.validateFieldValue(fieldName, value);
|
|
747
|
+
return {
|
|
748
|
+
sql: `${column} = ?`,
|
|
749
|
+
params: [this.convertValueForSQLite(value)]
|
|
750
|
+
};
|
|
751
|
+
case "$ne":
|
|
752
|
+
this.validateFieldValue(fieldName, value);
|
|
753
|
+
return {
|
|
754
|
+
sql: `${column} != ?`,
|
|
755
|
+
params: [this.convertValueForSQLite(value)]
|
|
756
|
+
};
|
|
757
|
+
case "$gt":
|
|
758
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
759
|
+
"id",
|
|
760
|
+
"string",
|
|
761
|
+
"number",
|
|
762
|
+
"date"
|
|
763
|
+
]);
|
|
764
|
+
this.validateFieldValue(fieldName, value);
|
|
765
|
+
return {
|
|
766
|
+
sql: `${column} > ?`,
|
|
767
|
+
params: [this.convertValueForSQLite(value)]
|
|
768
|
+
};
|
|
769
|
+
case "$gte":
|
|
770
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
771
|
+
"id",
|
|
772
|
+
"string",
|
|
773
|
+
"number",
|
|
774
|
+
"date"
|
|
775
|
+
]);
|
|
776
|
+
this.validateFieldValue(fieldName, value);
|
|
777
|
+
return {
|
|
778
|
+
sql: `${column} >= ?`,
|
|
779
|
+
params: [this.convertValueForSQLite(value)]
|
|
780
|
+
};
|
|
781
|
+
case "$lt":
|
|
782
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
783
|
+
"id",
|
|
784
|
+
"string",
|
|
785
|
+
"number",
|
|
786
|
+
"date"
|
|
787
|
+
]);
|
|
788
|
+
this.validateFieldValue(fieldName, value);
|
|
789
|
+
return {
|
|
790
|
+
sql: `${column} < ?`,
|
|
791
|
+
params: [this.convertValueForSQLite(value)]
|
|
792
|
+
};
|
|
793
|
+
case "$lte":
|
|
794
|
+
this.validateOperatorForType(operator, fieldType, [
|
|
795
|
+
"id",
|
|
796
|
+
"string",
|
|
797
|
+
"number",
|
|
798
|
+
"date"
|
|
799
|
+
]);
|
|
800
|
+
this.validateFieldValue(fieldName, value);
|
|
801
|
+
return {
|
|
802
|
+
sql: `${column} <= ?`,
|
|
803
|
+
params: [this.convertValueForSQLite(value)]
|
|
804
|
+
};
|
|
805
|
+
case "$in":
|
|
806
|
+
if (!Array.isArray(value)) {
|
|
807
|
+
throw new InvalidOperatorError(
|
|
808
|
+
`$in operator requires an array value for field ${fieldName}`,
|
|
809
|
+
fieldName,
|
|
810
|
+
operator,
|
|
811
|
+
fieldType
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
this.validateArrayValues(fieldName, value);
|
|
815
|
+
if (value.length === 0) {
|
|
816
|
+
return { sql: "1 = 0", params: [] };
|
|
817
|
+
}
|
|
818
|
+
const placeholders = value.map(() => "?").join(",");
|
|
819
|
+
const convertedValues = value.map((v) => this.convertValueForSQLite(v));
|
|
820
|
+
return {
|
|
821
|
+
sql: `${column} IN (${placeholders})`,
|
|
822
|
+
params: convertedValues
|
|
823
|
+
};
|
|
824
|
+
case "$nin":
|
|
825
|
+
if (!Array.isArray(value)) {
|
|
826
|
+
throw new InvalidOperatorError(
|
|
827
|
+
`$nin operator requires an array value for field ${fieldName}`,
|
|
828
|
+
fieldName,
|
|
829
|
+
operator,
|
|
830
|
+
fieldType
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
this.validateArrayValues(fieldName, value);
|
|
834
|
+
if (value.length === 0) {
|
|
835
|
+
return { sql: "", params: [] };
|
|
836
|
+
}
|
|
837
|
+
const ninPlaceholders = value.map(() => "?").join(",");
|
|
838
|
+
const convertedNinValues = value.map(
|
|
839
|
+
(v) => this.convertValueForSQLite(v)
|
|
840
|
+
);
|
|
841
|
+
return {
|
|
842
|
+
sql: `${column} NOT IN (${ninPlaceholders})`,
|
|
843
|
+
params: convertedNinValues
|
|
844
|
+
};
|
|
845
|
+
case "$exists":
|
|
846
|
+
if (typeof value !== "boolean") {
|
|
847
|
+
throw new InvalidOperatorError(
|
|
848
|
+
`$exists operator requires a boolean value for field ${fieldName}`,
|
|
849
|
+
fieldName,
|
|
850
|
+
operator,
|
|
851
|
+
fieldType
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
return value ? { sql: `${column} IS NOT NULL`, params: [] } : { sql: `${column} IS NULL`, params: [] };
|
|
855
|
+
case "$contains":
|
|
856
|
+
if (fieldType !== "stringset") {
|
|
857
|
+
throw new InvalidOperatorError(
|
|
858
|
+
`$contains operator is only supported for stringset fields, field ${fieldName} is type ${fieldType}`,
|
|
859
|
+
fieldName,
|
|
860
|
+
operator,
|
|
861
|
+
fieldType
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
if (typeof value !== "string") {
|
|
865
|
+
throw new InvalidOperatorError(
|
|
866
|
+
`$contains operator requires a string value for stringset field ${fieldName}`,
|
|
867
|
+
fieldName,
|
|
868
|
+
operator,
|
|
869
|
+
fieldType
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const junctionTableName = `${this.tableName}_${fieldName}`;
|
|
873
|
+
const foreignKeyColumn = `${this.modelName.toLowerCase()}_id`;
|
|
874
|
+
const quotedJunctionTable = quoteIdentifier(junctionTableName);
|
|
875
|
+
const quotedForeignKey = quoteIdentifier(foreignKeyColumn);
|
|
876
|
+
const quotedValueColumn = quoteIdentifier("value");
|
|
877
|
+
const quotedPrimaryKey = quoteIdentifier("id");
|
|
878
|
+
return {
|
|
879
|
+
sql: `EXISTS (SELECT 1 FROM ${quotedJunctionTable} WHERE ${quotedJunctionTable}.${quotedForeignKey} = ${this.quotedTableName}.${quotedPrimaryKey} AND ${quotedJunctionTable}.${quotedValueColumn} = ?)`,
|
|
880
|
+
params: [value]
|
|
881
|
+
};
|
|
882
|
+
default:
|
|
883
|
+
throw new InvalidOperatorError(
|
|
884
|
+
`Unsupported operator: ${operator} for field ${fieldName}`,
|
|
885
|
+
fieldName,
|
|
886
|
+
operator,
|
|
887
|
+
fieldType
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Build SELECT clause based on projection
|
|
893
|
+
*/
|
|
894
|
+
buildSelectClause(projection) {
|
|
895
|
+
if (!projection || Object.keys(projection).length === 0) {
|
|
896
|
+
return "*";
|
|
897
|
+
}
|
|
898
|
+
const includeFields = [];
|
|
899
|
+
const excludeFields = [];
|
|
900
|
+
let hasIncludes = false;
|
|
901
|
+
let hasExcludes = false;
|
|
902
|
+
for (const [field, include] of Object.entries(projection)) {
|
|
903
|
+
if (include === 1) {
|
|
904
|
+
includeFields.push(field);
|
|
905
|
+
hasIncludes = true;
|
|
906
|
+
} else if (include === 0) {
|
|
907
|
+
excludeFields.push(field);
|
|
908
|
+
hasExcludes = true;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (hasIncludes && hasExcludes) {
|
|
912
|
+
throw new InvalidOperatorError(
|
|
913
|
+
"Cannot mix inclusion and exclusion in projection",
|
|
914
|
+
"projection",
|
|
915
|
+
"mixed"
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
if (hasIncludes) {
|
|
919
|
+
if (!includeFields.includes("id")) {
|
|
920
|
+
includeFields.unshift("id");
|
|
921
|
+
}
|
|
922
|
+
const quoted = includeFields.map((field) => this.getQuotedField(field));
|
|
923
|
+
return quoted.join(", ");
|
|
924
|
+
} else if (hasExcludes) {
|
|
925
|
+
const allFields = Array.from(this.schema.keys());
|
|
926
|
+
const selectedFields = allFields.filter(
|
|
927
|
+
(field) => !excludeFields.includes(field)
|
|
928
|
+
);
|
|
929
|
+
const quoted = selectedFields.map((field) => this.getQuotedField(field));
|
|
930
|
+
return quoted.join(", ");
|
|
931
|
+
}
|
|
932
|
+
return "*";
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Build LIMIT clause
|
|
936
|
+
*/
|
|
937
|
+
buildLimitClause(options) {
|
|
938
|
+
if (options?.limit && options.limit > 0) {
|
|
939
|
+
return { sql: options.limit.toString() };
|
|
940
|
+
}
|
|
941
|
+
return { sql: "" };
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Build pagination WHERE clause from cursor
|
|
945
|
+
*/
|
|
946
|
+
buildPaginationClause(options, sortFields, sortDirections) {
|
|
947
|
+
if (!options?.uniqueStartKey || !sortFields || !sortDirections) {
|
|
948
|
+
return { sql: "", params: [] };
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
const cursor = CursorManager.decodeCursor(options.uniqueStartKey);
|
|
952
|
+
const direction = options.direction || 1;
|
|
953
|
+
return CursorManager.buildPaginationConditions(
|
|
954
|
+
cursor,
|
|
955
|
+
sortFields,
|
|
956
|
+
sortDirections,
|
|
957
|
+
direction,
|
|
958
|
+
(field) => this.getQuotedField(field)
|
|
959
|
+
);
|
|
960
|
+
} catch (error) {
|
|
961
|
+
console.warn("Invalid cursor provided, ignoring pagination:", error);
|
|
962
|
+
return { sql: "", params: [] };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Validate that field exists in schema
|
|
967
|
+
*/
|
|
968
|
+
validateField(fieldName) {
|
|
969
|
+
if (!this.schema.has(fieldName) && !SYSTEM_FIELDS.has(fieldName)) {
|
|
970
|
+
throw new InvalidFieldError(
|
|
971
|
+
`Unknown field: ${fieldName} in model ${this.modelName}`,
|
|
972
|
+
fieldName,
|
|
973
|
+
this.modelName
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Validate projection fields
|
|
979
|
+
*/
|
|
980
|
+
validateProjection(projection) {
|
|
981
|
+
for (const fieldName of Object.keys(projection)) {
|
|
982
|
+
this.validateField(fieldName);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Validate operator is supported for field type
|
|
987
|
+
*/
|
|
988
|
+
validateOperatorForType(operator, fieldType, allowedTypes) {
|
|
989
|
+
if (!fieldType) {
|
|
990
|
+
console.warn(`Field type not specified, allowing operator ${operator}`);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (!allowedTypes.includes(fieldType)) {
|
|
994
|
+
throw new InvalidOperatorError(
|
|
995
|
+
`Operator ${operator} is not supported for field type ${fieldType}. Allowed types: ${allowedTypes.join(
|
|
996
|
+
", "
|
|
997
|
+
)}`,
|
|
998
|
+
"unknown",
|
|
999
|
+
operator,
|
|
1000
|
+
fieldType
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Validate field value matches expected type
|
|
1006
|
+
*/
|
|
1007
|
+
validateFieldValue(fieldName, value) {
|
|
1008
|
+
const fieldOptions = this.schema.get(fieldName);
|
|
1009
|
+
if (!fieldOptions || !fieldOptions.type) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const expectedType = fieldOptions.type;
|
|
1013
|
+
const actualType = this.getValueType(value);
|
|
1014
|
+
if (!this.isTypeCompatible(expectedType, actualType, value)) {
|
|
1015
|
+
throw new InvalidOperatorError(
|
|
1016
|
+
`Field ${fieldName} expects ${expectedType}, got ${actualType}`,
|
|
1017
|
+
fieldName,
|
|
1018
|
+
"type_mismatch",
|
|
1019
|
+
expectedType
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Validate array values for $in/$nin operators
|
|
1025
|
+
*/
|
|
1026
|
+
validateArrayValues(fieldName, values) {
|
|
1027
|
+
for (const value of values) {
|
|
1028
|
+
this.validateFieldValue(fieldName, value);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Check if value is a primitive (not an object with operators)
|
|
1033
|
+
*/
|
|
1034
|
+
isPrimitiveValue(value) {
|
|
1035
|
+
if (value === null || value === void 0) {
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
const type = typeof value;
|
|
1039
|
+
return type === "string" || type === "number" || type === "boolean" || value instanceof Date;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Get the type of a value for validation
|
|
1043
|
+
*/
|
|
1044
|
+
getValueType(value) {
|
|
1045
|
+
if (value === null || value === void 0) {
|
|
1046
|
+
return "null";
|
|
1047
|
+
}
|
|
1048
|
+
if (value instanceof Date) {
|
|
1049
|
+
return "date";
|
|
1050
|
+
}
|
|
1051
|
+
return typeof value;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Check if value type is compatible with field type
|
|
1055
|
+
*/
|
|
1056
|
+
isTypeCompatible(expectedType, actualType, value) {
|
|
1057
|
+
if (actualType === "null") {
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
switch (expectedType) {
|
|
1061
|
+
case "id":
|
|
1062
|
+
return actualType === "string";
|
|
1063
|
+
// id fields are strings
|
|
1064
|
+
case "string":
|
|
1065
|
+
return actualType === "string";
|
|
1066
|
+
case "number":
|
|
1067
|
+
return actualType === "number" && !isNaN(value);
|
|
1068
|
+
case "boolean":
|
|
1069
|
+
return actualType === "boolean";
|
|
1070
|
+
case "date":
|
|
1071
|
+
return actualType === "date" || actualType === "string" && !isNaN(Date.parse(value));
|
|
1072
|
+
default:
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Convert value for SQLite compatibility
|
|
1078
|
+
*/
|
|
1079
|
+
convertValueForSQLite(value) {
|
|
1080
|
+
if (typeof value === "boolean") {
|
|
1081
|
+
return value ? 1 : 0;
|
|
1082
|
+
}
|
|
1083
|
+
return value;
|
|
1084
|
+
}
|
|
1085
|
+
normalizeDocumentIds(documents) {
|
|
1086
|
+
if (documents == null) {
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
const docArray = Array.isArray(documents) ? documents : [documents];
|
|
1090
|
+
const normalized = docArray.map((doc) => `${doc}`.trim()).filter((doc) => doc.length > 0);
|
|
1091
|
+
if (normalized.length === 0) {
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
1094
|
+
return Array.from(new Set(normalized));
|
|
1095
|
+
}
|
|
1096
|
+
buildDocumentClause(documents) {
|
|
1097
|
+
const normalized = this.normalizeDocumentIds(documents);
|
|
1098
|
+
if (normalized === null) {
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
if (normalized.length === 0) {
|
|
1102
|
+
return { sql: "1 = 0", params: [] };
|
|
1103
|
+
}
|
|
1104
|
+
const placeholders = normalized.map(() => "?").join(", ");
|
|
1105
|
+
return {
|
|
1106
|
+
sql: `${quoteIdentifier("_meta_doc_id")} IN (${placeholders})`,
|
|
1107
|
+
params: normalized
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// src/models/BaseModel.ts
|
|
1113
|
+
var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
|
|
1114
|
+
LogLevel2[LogLevel2["SILENT"] = 0] = "SILENT";
|
|
1115
|
+
LogLevel2[LogLevel2["ERROR"] = 1] = "ERROR";
|
|
1116
|
+
LogLevel2[LogLevel2["WARN"] = 2] = "WARN";
|
|
1117
|
+
LogLevel2[LogLevel2["INFO"] = 3] = "INFO";
|
|
1118
|
+
LogLevel2[LogLevel2["DEBUG"] = 4] = "DEBUG";
|
|
1119
|
+
LogLevel2[LogLevel2["VERBOSE"] = 5] = "VERBOSE";
|
|
1120
|
+
return LogLevel2;
|
|
1121
|
+
})(LogLevel || {});
|
|
1122
|
+
var Logger = class {
|
|
1123
|
+
static _logLevel = 1 /* ERROR */;
|
|
1124
|
+
static _logCallback = null;
|
|
1125
|
+
static setLogLevel(level) {
|
|
1126
|
+
this._logLevel = level;
|
|
1127
|
+
}
|
|
1128
|
+
static getLogLevel() {
|
|
1129
|
+
return this._logLevel;
|
|
1130
|
+
}
|
|
1131
|
+
static setLogCallback(callback) {
|
|
1132
|
+
this._logCallback = callback;
|
|
1133
|
+
}
|
|
1134
|
+
static error(message, ...args) {
|
|
1135
|
+
if (this._logLevel >= 1 /* ERROR */) {
|
|
1136
|
+
const fullMessage = args.length > 0 ? `${message} ${args.map(
|
|
1137
|
+
(arg) => typeof arg === "object" ? JSON.stringify(arg) : String(arg)
|
|
1138
|
+
).join(" ")}` : message;
|
|
1139
|
+
console.error(`[ERROR] ${message}`, ...args);
|
|
1140
|
+
if (this._logCallback) {
|
|
1141
|
+
this._logCallback(fullMessage, 1 /* ERROR */);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
static warn(message, ...args) {
|
|
1146
|
+
if (this._logLevel >= 2 /* WARN */) {
|
|
1147
|
+
const fullMessage = args.length > 0 ? `${message} ${args.map(
|
|
1148
|
+
(arg) => typeof arg === "object" ? JSON.stringify(arg) : String(arg)
|
|
1149
|
+
).join(" ")}` : message;
|
|
1150
|
+
console.warn(`[WARN] ${message}`, ...args);
|
|
1151
|
+
if (this._logCallback) {
|
|
1152
|
+
this._logCallback(fullMessage, 2 /* WARN */);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
static info(message, ...args) {
|
|
1157
|
+
if (this._logLevel >= 3 /* INFO */) {
|
|
1158
|
+
const fullMessage = args.length > 0 ? `${message} ${args.map(
|
|
1159
|
+
(arg) => typeof arg === "object" ? JSON.stringify(arg) : String(arg)
|
|
1160
|
+
).join(" ")}` : message;
|
|
1161
|
+
console.log(`[INFO] ${message}`, ...args);
|
|
1162
|
+
if (this._logCallback) {
|
|
1163
|
+
this._logCallback(fullMessage, 3 /* INFO */);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
static debug(message, ...args) {
|
|
1168
|
+
if (this._logLevel >= 4 /* DEBUG */) {
|
|
1169
|
+
const fullMessage = args.length > 0 ? `${message} ${args.map(
|
|
1170
|
+
(arg) => typeof arg === "object" ? JSON.stringify(arg) : String(arg)
|
|
1171
|
+
).join(" ")}` : message;
|
|
1172
|
+
console.log(`[DEBUG] ${message}`, ...args);
|
|
1173
|
+
if (this._logCallback) {
|
|
1174
|
+
this._logCallback(fullMessage, 4 /* DEBUG */);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
static verbose(message, ...args) {
|
|
1179
|
+
if (this._logLevel >= 5 /* VERBOSE */) {
|
|
1180
|
+
const fullMessage = args.length > 0 ? `${message} ${args.map(
|
|
1181
|
+
(arg) => typeof arg === "object" ? JSON.stringify(arg) : String(arg)
|
|
1182
|
+
).join(" ")}` : message;
|
|
1183
|
+
console.log(`[VERBOSE] ${message}`, ...args);
|
|
1184
|
+
if (this._logCallback) {
|
|
1185
|
+
this._logCallback(fullMessage, 5 /* VERBOSE */);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
var UniqueConstraintViolationError = class _UniqueConstraintViolationError extends Error {
|
|
1191
|
+
modelName;
|
|
1192
|
+
constraintName;
|
|
1193
|
+
fields;
|
|
1194
|
+
recordIdAttempted;
|
|
1195
|
+
// ID of the record that failed to save/update
|
|
1196
|
+
conflictingRecordId;
|
|
1197
|
+
// ID of the record that already has the unique value
|
|
1198
|
+
constructor(message, modelName, constraintName, fields, recordIdAttempted, conflictingRecordId) {
|
|
1199
|
+
super(message);
|
|
1200
|
+
this.name = "UniqueConstraintViolationError";
|
|
1201
|
+
this.modelName = modelName;
|
|
1202
|
+
this.constraintName = constraintName;
|
|
1203
|
+
this.fields = fields;
|
|
1204
|
+
this.recordIdAttempted = recordIdAttempted;
|
|
1205
|
+
this.conflictingRecordId = conflictingRecordId;
|
|
1206
|
+
Object.setPrototypeOf(this, _UniqueConstraintViolationError.prototype);
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
var RecordNotFoundError = class _RecordNotFoundError extends Error {
|
|
1210
|
+
constructor(message) {
|
|
1211
|
+
super(message);
|
|
1212
|
+
this.name = "RecordNotFoundError";
|
|
1213
|
+
Object.setPrototypeOf(this, _RecordNotFoundError.prototype);
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
function generateULID() {
|
|
1217
|
+
return ulid();
|
|
1218
|
+
}
|
|
1219
|
+
var SCHEMA_ACCESSORS_KEY = /* @__PURE__ */ Symbol("jsBaoSchemaAccessors");
|
|
1220
|
+
var BaseModel = class _BaseModel {
|
|
1221
|
+
static modelName;
|
|
1222
|
+
static listenersMap = /* @__PURE__ */ new Map();
|
|
1223
|
+
// Default document resolution mappings (session-scoped)
|
|
1224
|
+
static modelNameToDefaultDocId = /* @__PURE__ */ new Map();
|
|
1225
|
+
static globalDefaultDocId;
|
|
1226
|
+
static defaultDocChangedListeners = /* @__PURE__ */ new Set();
|
|
1227
|
+
static modelDocMappingChangedListeners = /* @__PURE__ */ new Set();
|
|
1228
|
+
// These were for tracking initialization state, which is now more centralized in ModelRegistry
|
|
1229
|
+
// and the main initializeORM flow. We'll rely on the Model's static `initialize` method.
|
|
1230
|
+
// private static initializedModels: Set<string> = new Set();
|
|
1231
|
+
// private static initializationPromises: Map<string, Promise<void>> = new Map();
|
|
1232
|
+
// Default document constants for legacy mode compatibility
|
|
1233
|
+
static DEFAULT_LEGACY_DOC_ID = "__legacy_default__";
|
|
1234
|
+
// private static readonly DEFAULT_PERMISSION: DocumentPermissionHint = "read-write";
|
|
1235
|
+
// dbInstance will be set per model via its static initialize, but query methods need access.
|
|
1236
|
+
// This will be a single instance shared across all models once initialized.
|
|
1237
|
+
static dbInstance = null;
|
|
1238
|
+
// Multi-document management (now used for both legacy and multi-document modes)
|
|
1239
|
+
static connectedDocuments = /* @__PURE__ */ new Map();
|
|
1240
|
+
static documentYMaps = /* @__PURE__ */ new Map();
|
|
1241
|
+
// Maps docId to YMap for that document
|
|
1242
|
+
// Copy-on-write state management
|
|
1243
|
+
_localChanges = null;
|
|
1244
|
+
_isDirty = false;
|
|
1245
|
+
_isLoadingFromYjs = false;
|
|
1246
|
+
// Flag to distinguish loading vs user changes
|
|
1247
|
+
// StringSet field caching
|
|
1248
|
+
_stringSetFields = /* @__PURE__ */ new Map();
|
|
1249
|
+
// Multi-document instance metadata
|
|
1250
|
+
_metaDocId = null;
|
|
1251
|
+
_metaPermissionHint = null;
|
|
1252
|
+
/**
|
|
1253
|
+
* Returns the document id this instance is associated with, or null if not resolved yet.
|
|
1254
|
+
*/
|
|
1255
|
+
getDocumentId() {
|
|
1256
|
+
return this._metaDocId;
|
|
1257
|
+
}
|
|
1258
|
+
// id is now a plain property. Subclasses will define it with @Field.
|
|
1259
|
+
id;
|
|
1260
|
+
type;
|
|
1261
|
+
// This should be the modelName from ModelOptions
|
|
1262
|
+
// Static logging methods
|
|
1263
|
+
static setLogLevel(level) {
|
|
1264
|
+
Logger.setLogLevel(level);
|
|
1265
|
+
}
|
|
1266
|
+
static getLogLevel() {
|
|
1267
|
+
return Logger.getLogLevel();
|
|
1268
|
+
}
|
|
1269
|
+
// ---- Default doc resolution: static APIs ----
|
|
1270
|
+
static setModelDefaultDocumentId(modelName, docId) {
|
|
1271
|
+
const previous = this.modelNameToDefaultDocId.get(modelName);
|
|
1272
|
+
this.modelNameToDefaultDocId.set(modelName, docId);
|
|
1273
|
+
for (const listener of this.modelDocMappingChangedListeners) {
|
|
1274
|
+
try {
|
|
1275
|
+
listener({ modelName, previous, current: docId });
|
|
1276
|
+
} catch {
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
static removeModelDefaultDocumentId(modelName) {
|
|
1281
|
+
const previous = this.modelNameToDefaultDocId.get(modelName);
|
|
1282
|
+
this.modelNameToDefaultDocId.delete(modelName);
|
|
1283
|
+
for (const listener of this.modelDocMappingChangedListeners) {
|
|
1284
|
+
try {
|
|
1285
|
+
listener({ modelName, previous, current: void 0 });
|
|
1286
|
+
} catch {
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
static clearModelDefaultDocumentIds() {
|
|
1291
|
+
const entries = Array.from(this.modelNameToDefaultDocId.entries());
|
|
1292
|
+
this.modelNameToDefaultDocId.clear();
|
|
1293
|
+
for (const [modelName, previous] of entries) {
|
|
1294
|
+
for (const listener of this.modelDocMappingChangedListeners) {
|
|
1295
|
+
try {
|
|
1296
|
+
listener({ modelName, previous, current: void 0 });
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
static setGlobalDefaultDocumentId(docId) {
|
|
1303
|
+
const previous = this.globalDefaultDocId;
|
|
1304
|
+
this.globalDefaultDocId = docId;
|
|
1305
|
+
for (const listener of this.defaultDocChangedListeners) {
|
|
1306
|
+
try {
|
|
1307
|
+
listener({ previous, current: docId });
|
|
1308
|
+
} catch {
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
static clearGlobalDefaultDocumentId() {
|
|
1313
|
+
const previous = this.globalDefaultDocId;
|
|
1314
|
+
this.globalDefaultDocId = void 0;
|
|
1315
|
+
for (const listener of this.defaultDocChangedListeners) {
|
|
1316
|
+
try {
|
|
1317
|
+
listener({ previous, current: void 0 });
|
|
1318
|
+
} catch {
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
static getModelDefaultDocumentMapping() {
|
|
1323
|
+
return Object.fromEntries(this.modelNameToDefaultDocId.entries());
|
|
1324
|
+
}
|
|
1325
|
+
static getDocumentIdForModel(modelName) {
|
|
1326
|
+
return this.modelNameToDefaultDocId.get(modelName);
|
|
1327
|
+
}
|
|
1328
|
+
static getGlobalDefaultDocumentId() {
|
|
1329
|
+
return this.globalDefaultDocId;
|
|
1330
|
+
}
|
|
1331
|
+
static onDefaultDocChanged(listener) {
|
|
1332
|
+
this.defaultDocChangedListeners.add(listener);
|
|
1333
|
+
return () => this.defaultDocChangedListeners.delete(listener);
|
|
1334
|
+
}
|
|
1335
|
+
static onModelDocMappingChanged(listener) {
|
|
1336
|
+
this.modelDocMappingChangedListeners.add(listener);
|
|
1337
|
+
return () => this.modelDocMappingChangedListeners.delete(listener);
|
|
1338
|
+
}
|
|
1339
|
+
// Helper used by DocumentManager to clear mappings for a given docId when disconnected
|
|
1340
|
+
static _clearMappingsForDocId(docId) {
|
|
1341
|
+
const toRemove = [];
|
|
1342
|
+
for (const [modelName, mappedDocId] of this.modelNameToDefaultDocId) {
|
|
1343
|
+
if (mappedDocId === docId) {
|
|
1344
|
+
toRemove.push(modelName);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
for (const modelName of toRemove) {
|
|
1348
|
+
this.removeModelDefaultDocumentId(modelName);
|
|
1349
|
+
}
|
|
1350
|
+
if (this.globalDefaultDocId === docId) {
|
|
1351
|
+
this.clearGlobalDefaultDocumentId();
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
static attachFieldAccessorsSet(modelClass) {
|
|
1355
|
+
const existing = modelClass[SCHEMA_ACCESSORS_KEY] ?? /* @__PURE__ */ new Set();
|
|
1356
|
+
modelClass[SCHEMA_ACCESSORS_KEY] = existing;
|
|
1357
|
+
return existing;
|
|
1358
|
+
}
|
|
1359
|
+
static attachFieldAccessors(modelClass, fields) {
|
|
1360
|
+
const attached = _BaseModel.attachFieldAccessorsSet(modelClass);
|
|
1361
|
+
const prototype = modelClass.prototype;
|
|
1362
|
+
for (const fieldName of fields.keys()) {
|
|
1363
|
+
if (fieldName === "id") continue;
|
|
1364
|
+
if (attached.has(fieldName)) continue;
|
|
1365
|
+
const existingDescriptor = Object.getOwnPropertyDescriptor(
|
|
1366
|
+
prototype,
|
|
1367
|
+
fieldName
|
|
1368
|
+
);
|
|
1369
|
+
if (existingDescriptor && !existingDescriptor.configurable) {
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
Object.defineProperty(prototype, fieldName, {
|
|
1373
|
+
configurable: true,
|
|
1374
|
+
enumerable: true,
|
|
1375
|
+
get() {
|
|
1376
|
+
return this.getValue(fieldName);
|
|
1377
|
+
},
|
|
1378
|
+
set(value) {
|
|
1379
|
+
this.setValue(fieldName, value);
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
attached.add(fieldName);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
constructor(data = {}) {
|
|
1386
|
+
if (new.target === _BaseModel) {
|
|
1387
|
+
throw new Error("BaseModel cannot be instantiated directly.");
|
|
1388
|
+
}
|
|
1389
|
+
const modelConstructor = this.constructor;
|
|
1390
|
+
const schema = modelConstructor.getSchema();
|
|
1391
|
+
const verboseEnabled = Logger.getLogLevel() >= 5 /* VERBOSE */;
|
|
1392
|
+
if (!schema || !schema.fields) {
|
|
1393
|
+
throw new Error(
|
|
1394
|
+
`[${modelConstructor.name}] Schema not loaded. Ensure the model is properly initialized.`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (verboseEnabled) {
|
|
1398
|
+
Logger.verbose(
|
|
1399
|
+
`BaseModel Constructor - ${modelConstructor.name}: Initializing with data:`,
|
|
1400
|
+
data
|
|
1401
|
+
);
|
|
1402
|
+
Logger.verbose(
|
|
1403
|
+
`BaseModel Constructor - ${modelConstructor.name}: Schema fields received:`,
|
|
1404
|
+
schema.fields
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
const idFieldOptions = schema.fields.get("id");
|
|
1408
|
+
if (data.id) {
|
|
1409
|
+
this.id = data.id;
|
|
1410
|
+
} else if (idFieldOptions?.autoAssign) {
|
|
1411
|
+
this.id = generateULID();
|
|
1412
|
+
} else if (idFieldOptions?.default) {
|
|
1413
|
+
this.id = typeof idFieldOptions.default === "function" ? idFieldOptions.default() : idFieldOptions.default;
|
|
1414
|
+
} else {
|
|
1415
|
+
throw new Error(
|
|
1416
|
+
`[${modelConstructor.name}] No ID provided and no autoAssign or default configured for id field`
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
const isLoadingExistingRecord = data.id && Object.keys(data).length === 1;
|
|
1420
|
+
if (verboseEnabled) {
|
|
1421
|
+
Logger.verbose(
|
|
1422
|
+
`[BaseModel Constructor - ${modelConstructor.name}] Is loading existing record: ${isLoadingExistingRecord}, ID: ${this.id}`
|
|
1423
|
+
);
|
|
1424
|
+
Logger.verbose(
|
|
1425
|
+
`[BaseModel Constructor - ${modelConstructor.name}] Connected documents:`,
|
|
1426
|
+
Array.from(modelConstructor.connectedDocuments.keys())
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
if (isLoadingExistingRecord) {
|
|
1430
|
+
let recordYMap = void 0;
|
|
1431
|
+
let foundDocId = void 0;
|
|
1432
|
+
for (const [docId] of modelConstructor.connectedDocuments) {
|
|
1433
|
+
if (verboseEnabled) {
|
|
1434
|
+
Logger.verbose(
|
|
1435
|
+
`[BaseModel Constructor - ${modelConstructor.name}] Searching in document: ${docId}`
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
const documentYMap = modelConstructor.documentYMaps.get(
|
|
1439
|
+
`${docId}_${schema.options.name}`
|
|
1440
|
+
);
|
|
1441
|
+
if (verboseEnabled) {
|
|
1442
|
+
Logger.verbose(
|
|
1443
|
+
`[BaseModel Constructor - ${modelConstructor.name}] Document YMap found: ${!!documentYMap}`
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
if (documentYMap) {
|
|
1447
|
+
const foundRecord = documentYMap.get(this.id);
|
|
1448
|
+
if (verboseEnabled) {
|
|
1449
|
+
Logger.verbose(
|
|
1450
|
+
`[BaseModel Constructor - ${modelConstructor.name}] Record found in document ${docId}: ${!!foundRecord}`
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
if (foundRecord) {
|
|
1454
|
+
recordYMap = foundRecord;
|
|
1455
|
+
foundDocId = docId;
|
|
1456
|
+
break;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (recordYMap && foundDocId) {
|
|
1461
|
+
this._isLoadingFromYjs = true;
|
|
1462
|
+
this._isDirty = false;
|
|
1463
|
+
this._localChanges = null;
|
|
1464
|
+
this._metaDocId = foundDocId;
|
|
1465
|
+
const connectedDoc = modelConstructor.connectedDocuments.get(foundDocId);
|
|
1466
|
+
if (connectedDoc) {
|
|
1467
|
+
this._metaPermissionHint = connectedDoc.permissionHint;
|
|
1468
|
+
}
|
|
1469
|
+
if (schema.options && schema.options.name) {
|
|
1470
|
+
this.type = schema.options.name;
|
|
1471
|
+
}
|
|
1472
|
+
if (verboseEnabled) {
|
|
1473
|
+
Logger.verbose(
|
|
1474
|
+
`[BaseModel Constructor - ${modelConstructor.name}] ID INITIALIZED TO:`,
|
|
1475
|
+
this.id
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
this._isLoadingFromYjs = false;
|
|
1482
|
+
if (data && Object.keys(data).length > 0) {
|
|
1483
|
+
for (const [
|
|
1484
|
+
fieldKey,
|
|
1485
|
+
fieldOptions
|
|
1486
|
+
] of schema.fields.entries()) {
|
|
1487
|
+
if (fieldKey === "id") continue;
|
|
1488
|
+
if (data.hasOwnProperty(fieldKey)) {
|
|
1489
|
+
this.setValue(fieldKey, data[fieldKey]);
|
|
1490
|
+
} else if (fieldOptions.default !== void 0) {
|
|
1491
|
+
const defaultValue = typeof fieldOptions.default === "function" ? fieldOptions.default() : fieldOptions.default;
|
|
1492
|
+
this.setValue(fieldKey, defaultValue);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (schema.options && schema.options.name) {
|
|
1497
|
+
this.type = schema.options.name;
|
|
1498
|
+
} else {
|
|
1499
|
+
Logger.warn(
|
|
1500
|
+
`[${modelConstructor.name}] Schema missing options.name, 'type' field may not be set correctly.`
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
if (!this.id) {
|
|
1504
|
+
Logger.error(
|
|
1505
|
+
`[BaseModel Constructor - ${modelConstructor.name}] ID is STILL UNDEFINED. Current instance state:`,
|
|
1506
|
+
JSON.stringify(this)
|
|
1507
|
+
);
|
|
1508
|
+
throw new Error(
|
|
1509
|
+
`[${modelConstructor.name}] ID is missing after initialization. Ensure 'id' field is defined in the model's schema with autoAssign:true or a default value, or an id is provided during instantiation.`
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
Logger.verbose(
|
|
1513
|
+
`[BaseModel Constructor - ${modelConstructor.name}] ID INITIALIZED TO:`,
|
|
1514
|
+
this.id
|
|
1515
|
+
);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
// Copy-on-write helper methods
|
|
1519
|
+
ensureLocalChanges() {
|
|
1520
|
+
if (this._localChanges === null) {
|
|
1521
|
+
this._localChanges = {};
|
|
1522
|
+
}
|
|
1523
|
+
return this._localChanges;
|
|
1524
|
+
}
|
|
1525
|
+
hasLocalChange(fieldKey) {
|
|
1526
|
+
return this._localChanges !== null && fieldKey in this._localChanges;
|
|
1527
|
+
}
|
|
1528
|
+
getFromYjs(fieldKey) {
|
|
1529
|
+
const modelConstructor = this.constructor;
|
|
1530
|
+
const schema = modelConstructor.getSchema();
|
|
1531
|
+
const modelName = schema?.options?.name;
|
|
1532
|
+
const verboseEnabled = Logger.getLogLevel() >= 5 /* VERBOSE */;
|
|
1533
|
+
if (verboseEnabled) {
|
|
1534
|
+
Logger.verbose(
|
|
1535
|
+
`[getFromYjs] Called for field '${fieldKey}' on model ${modelName}, instance ID: ${this.id}`
|
|
1536
|
+
);
|
|
1537
|
+
Logger.verbose(`[getFromYjs] this._metaDocId: ${this._metaDocId}`);
|
|
1538
|
+
Logger.verbose(
|
|
1539
|
+
`[getFromYjs] documentYMaps available: ${!!modelConstructor.documentYMaps}`
|
|
1540
|
+
);
|
|
1541
|
+
Logger.verbose(
|
|
1542
|
+
`[getFromYjs] connectedDocuments available: ${!!modelConstructor.connectedDocuments}`
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
let recordYMap = void 0;
|
|
1546
|
+
const docId = this._metaDocId;
|
|
1547
|
+
if (verboseEnabled) {
|
|
1548
|
+
Logger.verbose(`[getFromYjs] Using document ID: ${docId}`);
|
|
1549
|
+
}
|
|
1550
|
+
const documentYMapKey = `${docId}_${modelName}`;
|
|
1551
|
+
if (verboseEnabled) {
|
|
1552
|
+
Logger.verbose(
|
|
1553
|
+
`[getFromYjs] Looking for documentYMap with key: ${documentYMapKey}`
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
|
|
1557
|
+
if (verboseEnabled) {
|
|
1558
|
+
Logger.verbose(`[getFromYjs] DocumentYMap found: ${!!documentYMap}`);
|
|
1559
|
+
}
|
|
1560
|
+
if (documentYMap) {
|
|
1561
|
+
if (verboseEnabled) {
|
|
1562
|
+
Logger.verbose(
|
|
1563
|
+
`[getFromYjs] DocumentYMap keys: [${Array.from(
|
|
1564
|
+
documentYMap.keys()
|
|
1565
|
+
).join(", ")}]`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
recordYMap = documentYMap.get(this.id);
|
|
1569
|
+
if (verboseEnabled) {
|
|
1570
|
+
Logger.verbose(
|
|
1571
|
+
`[getFromYjs] RecordYMap found for ID '${this.id}': ${!!recordYMap}`
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
if (recordYMap) {
|
|
1575
|
+
if (verboseEnabled) {
|
|
1576
|
+
Logger.verbose(
|
|
1577
|
+
`[getFromYjs] RecordYMap keys: [${Array.from(
|
|
1578
|
+
recordYMap.keys()
|
|
1579
|
+
).join(", ")}]`
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
const fieldValue2 = recordYMap.get(fieldKey);
|
|
1583
|
+
if (verboseEnabled) {
|
|
1584
|
+
Logger.verbose(
|
|
1585
|
+
`[getFromYjs] Field '${fieldKey}' value: ${fieldValue2}`
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
if (!recordYMap) {
|
|
1591
|
+
if (verboseEnabled) {
|
|
1592
|
+
Logger.verbose(
|
|
1593
|
+
`[getFromYjs] No recordYMap found, falling back to schema defaults`
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
const fieldOptions = schema?.fields?.get(fieldKey);
|
|
1597
|
+
if (fieldOptions?.default !== void 0) {
|
|
1598
|
+
const defaultValue = typeof fieldOptions.default === "function" ? fieldOptions.default() : fieldOptions.default;
|
|
1599
|
+
if (verboseEnabled) {
|
|
1600
|
+
Logger.verbose(
|
|
1601
|
+
`[getFromYjs] Returning schema default: ${defaultValue}`
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
return defaultValue;
|
|
1605
|
+
}
|
|
1606
|
+
if (verboseEnabled) {
|
|
1607
|
+
Logger.verbose(`[getFromYjs] No schema default, returning undefined`);
|
|
1608
|
+
}
|
|
1609
|
+
return void 0;
|
|
1610
|
+
}
|
|
1611
|
+
const fieldValue = recordYMap.get(fieldKey);
|
|
1612
|
+
if (verboseEnabled) {
|
|
1613
|
+
Logger.verbose(`[getFromYjs] Returning field value: ${fieldValue}`);
|
|
1614
|
+
}
|
|
1615
|
+
return fieldValue;
|
|
1616
|
+
}
|
|
1617
|
+
getValue(fieldKey) {
|
|
1618
|
+
const verboseEnabled = Logger.getLogLevel() >= 5 /* VERBOSE */;
|
|
1619
|
+
if (verboseEnabled) {
|
|
1620
|
+
Logger.verbose(
|
|
1621
|
+
`[getValue] Called for field '${fieldKey}' on instance ID: ${this.id}`
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
const schema = this.constructor.getSchema();
|
|
1625
|
+
const fieldOptions = schema?.fields?.get(fieldKey);
|
|
1626
|
+
if (fieldOptions?.type === "stringset") {
|
|
1627
|
+
if (verboseEnabled) {
|
|
1628
|
+
Logger.verbose(
|
|
1629
|
+
`[getValue] Field '${fieldKey}' is StringSet type, returning StringSet instance`
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
return this.getOrCreateStringSet(fieldKey);
|
|
1633
|
+
}
|
|
1634
|
+
if (this.hasLocalChange(fieldKey)) {
|
|
1635
|
+
if (verboseEnabled) {
|
|
1636
|
+
Logger.verbose(
|
|
1637
|
+
`[getValue] Field '${fieldKey}' has local changes: ${this._localChanges[fieldKey]}`
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
return this._localChanges[fieldKey];
|
|
1641
|
+
}
|
|
1642
|
+
if (verboseEnabled) {
|
|
1643
|
+
Logger.verbose(
|
|
1644
|
+
`[getValue] Field '${fieldKey}' no local changes, calling getFromYjs`
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
const result = this.getFromYjs(fieldKey);
|
|
1648
|
+
if (verboseEnabled) {
|
|
1649
|
+
Logger.verbose(
|
|
1650
|
+
`[getValue] Field '${fieldKey}' returning from getFromYjs: ${result}`
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
return result;
|
|
1654
|
+
}
|
|
1655
|
+
setValue(fieldKey, value) {
|
|
1656
|
+
const schema = this.constructor.getSchema();
|
|
1657
|
+
const fieldOptions = schema?.fields?.get(fieldKey);
|
|
1658
|
+
if (fieldOptions?.type === "stringset") {
|
|
1659
|
+
throw new Error(
|
|
1660
|
+
`Cannot directly assign to StringSet field '${fieldKey}'. Use the StringSet methods (add, remove, clear) instead.`
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
if (!this._isLoadingFromYjs) {
|
|
1664
|
+
}
|
|
1665
|
+
const changes = this.ensureLocalChanges();
|
|
1666
|
+
changes[fieldKey] = value;
|
|
1667
|
+
this._isDirty = true;
|
|
1668
|
+
}
|
|
1669
|
+
get isDirty() {
|
|
1670
|
+
return this._isDirty;
|
|
1671
|
+
}
|
|
1672
|
+
get hasUnsavedChanges() {
|
|
1673
|
+
return this._localChanges !== null && Object.keys(this._localChanges).length > 0;
|
|
1674
|
+
}
|
|
1675
|
+
clearLocalChanges() {
|
|
1676
|
+
this._localChanges = null;
|
|
1677
|
+
this._isDirty = false;
|
|
1678
|
+
}
|
|
1679
|
+
discardChanges() {
|
|
1680
|
+
this.clearLocalChanges();
|
|
1681
|
+
}
|
|
1682
|
+
// Validation methods
|
|
1683
|
+
validateFieldValue(fieldKey, value) {
|
|
1684
|
+
const schema = this.constructor.getSchema();
|
|
1685
|
+
const fieldOptions = schema.fields.get(fieldKey);
|
|
1686
|
+
if (!fieldOptions) {
|
|
1687
|
+
throw new Error(`Unknown field: ${fieldKey}`);
|
|
1688
|
+
}
|
|
1689
|
+
if (fieldOptions.required && (value === null || value === void 0)) {
|
|
1690
|
+
throw new Error(`Field ${fieldKey} is required`);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
validateBeforeSave() {
|
|
1694
|
+
const schema = this.constructor.getSchema();
|
|
1695
|
+
for (const [fieldKey, fieldOptions] of schema.fields.entries()) {
|
|
1696
|
+
const currentValue = this.getValue(fieldKey);
|
|
1697
|
+
if (fieldOptions.required && (currentValue === null || currentValue === void 0)) {
|
|
1698
|
+
throw new Error(`Field ${fieldKey} is required before save`);
|
|
1699
|
+
}
|
|
1700
|
+
if (fieldOptions.type === "stringset" && currentValue instanceof StringSet) {
|
|
1701
|
+
if (fieldOptions.maxCount && currentValue.size > fieldOptions.maxCount) {
|
|
1702
|
+
throw new Error(
|
|
1703
|
+
`StringSet field '${fieldKey}' exceeds maximum count of ${fieldOptions.maxCount}. Current count: ${currentValue.size}`
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
if (fieldOptions.maxLength) {
|
|
1707
|
+
for (const value of currentValue) {
|
|
1708
|
+
if (value.length > fieldOptions.maxLength) {
|
|
1709
|
+
throw new Error(
|
|
1710
|
+
`StringSet field '${fieldKey}' contains a string that exceeds maximum length of ${fieldOptions.maxLength}: "${value}" (length: ${value.length})`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
// State inspection helpers
|
|
1719
|
+
getChangedFields() {
|
|
1720
|
+
return this._localChanges ? Object.keys(this._localChanges) : [];
|
|
1721
|
+
}
|
|
1722
|
+
getOriginalValue(fieldKey) {
|
|
1723
|
+
return this.getFromYjs(fieldKey);
|
|
1724
|
+
}
|
|
1725
|
+
getCurrentValue(fieldKey) {
|
|
1726
|
+
return this.getValue(fieldKey);
|
|
1727
|
+
}
|
|
1728
|
+
hasFieldChanged(fieldKey) {
|
|
1729
|
+
return this.hasLocalChange(fieldKey);
|
|
1730
|
+
}
|
|
1731
|
+
// StringSet support methods
|
|
1732
|
+
markStringSetChange(fieldName, operation, value) {
|
|
1733
|
+
const changes = this.ensureLocalChanges();
|
|
1734
|
+
if (!changes[fieldName]) {
|
|
1735
|
+
changes[fieldName] = {
|
|
1736
|
+
type: "stringset",
|
|
1737
|
+
additions: /* @__PURE__ */ new Set(),
|
|
1738
|
+
removals: /* @__PURE__ */ new Set()
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
const stringSetChanges = changes[fieldName];
|
|
1742
|
+
if (operation === "clear") {
|
|
1743
|
+
const currentValues = this.getStringSetCurrentValues(fieldName);
|
|
1744
|
+
stringSetChanges.additions.clear();
|
|
1745
|
+
stringSetChanges.removals = new Set(currentValues);
|
|
1746
|
+
} else if (operation === "add" && value) {
|
|
1747
|
+
stringSetChanges.removals.delete(value);
|
|
1748
|
+
stringSetChanges.additions.add(value);
|
|
1749
|
+
} else if (operation === "remove" && value) {
|
|
1750
|
+
stringSetChanges.additions.delete(value);
|
|
1751
|
+
stringSetChanges.removals.add(value);
|
|
1752
|
+
}
|
|
1753
|
+
this._isDirty = true;
|
|
1754
|
+
}
|
|
1755
|
+
getStringSetCurrentValues(fieldName) {
|
|
1756
|
+
const yjsData = this.getFromYjs(fieldName);
|
|
1757
|
+
if (yjsData && typeof yjsData === "object") {
|
|
1758
|
+
return Object.keys(yjsData);
|
|
1759
|
+
}
|
|
1760
|
+
return [];
|
|
1761
|
+
}
|
|
1762
|
+
getStringSetFromYjs(fieldName) {
|
|
1763
|
+
const yjsData = this.getFromYjs(fieldName);
|
|
1764
|
+
if (yjsData && typeof yjsData === "object") {
|
|
1765
|
+
return Object.keys(yjsData);
|
|
1766
|
+
}
|
|
1767
|
+
return [];
|
|
1768
|
+
}
|
|
1769
|
+
getOrCreateStringSet(fieldName) {
|
|
1770
|
+
if (!this._stringSetFields.has(fieldName)) {
|
|
1771
|
+
const initialValues = this.getStringSetFromYjs(fieldName);
|
|
1772
|
+
const pendingChanges = this._localChanges?.[fieldName];
|
|
1773
|
+
let currentValues = new Set(initialValues);
|
|
1774
|
+
if (pendingChanges && pendingChanges.type === "stringset") {
|
|
1775
|
+
for (const addition of pendingChanges.additions) {
|
|
1776
|
+
currentValues.add(addition);
|
|
1777
|
+
}
|
|
1778
|
+
for (const removal of pendingChanges.removals) {
|
|
1779
|
+
currentValues.delete(removal);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
const stringSet = new StringSet(
|
|
1783
|
+
this,
|
|
1784
|
+
fieldName,
|
|
1785
|
+
Array.from(currentValues)
|
|
1786
|
+
);
|
|
1787
|
+
this._stringSetFields.set(fieldName, stringSet);
|
|
1788
|
+
}
|
|
1789
|
+
return this._stringSetFields.get(fieldName);
|
|
1790
|
+
}
|
|
1791
|
+
// Legacy initialize method - removed (explicit document required)
|
|
1792
|
+
static async initialize(_yDoc, _db) {
|
|
1793
|
+
throw new Error(
|
|
1794
|
+
`[${this.name}] initialize(yDoc, db) is no longer supported. Use initializeForDocument(yDoc, db, docId, permission)`
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
// New method for multi-document initialization
|
|
1798
|
+
static async initializeForDocument(yDoc, db, docId, permissionHint) {
|
|
1799
|
+
const schema = this.getSchema?.();
|
|
1800
|
+
const verboseEnabled = Logger.getLogLevel() >= 5 /* VERBOSE */;
|
|
1801
|
+
if (!schema || !schema.options || !schema.options.name || !schema.resolvedUniqueConstraints) {
|
|
1802
|
+
throw new Error(
|
|
1803
|
+
`[${this.name}] Model schema is not registered, missing options, or missing resolvedUniqueConstraints. Did you forget to use the @Model decorator or has the schema structure changed?`
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
const modelName = schema.options.name;
|
|
1807
|
+
if (verboseEnabled) {
|
|
1808
|
+
Logger.verbose(
|
|
1809
|
+
`[${this.name}] Initializing model ${modelName} for document ${docId} (permission: ${permissionHint})...`
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
if (!_BaseModel.dbInstance) {
|
|
1813
|
+
_BaseModel.dbInstance = db;
|
|
1814
|
+
}
|
|
1815
|
+
this.connectedDocuments.set(docId, {
|
|
1816
|
+
yDoc,
|
|
1817
|
+
permissionHint
|
|
1818
|
+
});
|
|
1819
|
+
const documentYMap = yDoc.getMap(modelName);
|
|
1820
|
+
this.documentYMaps.set(
|
|
1821
|
+
`${docId}_${modelName}`,
|
|
1822
|
+
documentYMap
|
|
1823
|
+
);
|
|
1824
|
+
const stringSetFieldNames = [];
|
|
1825
|
+
schema.fields.forEach((fieldOptions, fieldName) => {
|
|
1826
|
+
if (fieldOptions?.type === "stringset") {
|
|
1827
|
+
stringSetFieldNames.push(fieldName);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
const documentRecordCount = typeof documentYMap.size === "number" ? documentYMap.size : Array.from(documentYMap.keys()).length;
|
|
1831
|
+
const shouldReindexJunctionTables = stringSetFieldNames.length > 0 && documentRecordCount > 0;
|
|
1832
|
+
if (verboseEnabled) {
|
|
1833
|
+
Logger.verbose(
|
|
1834
|
+
`[${this.name}] Document YMap initialized for ${modelName}/${docId} with keys:`,
|
|
1835
|
+
Array.from(documentYMap.keys())
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
if (verboseEnabled) {
|
|
1839
|
+
Logger.verbose(
|
|
1840
|
+
`[${this.name}] Ensuring DB table exists for ${modelName}...`
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
await db.createTable(modelName, schema.fields, schema.options);
|
|
1844
|
+
if (stringSetFieldNames.length > 0) {
|
|
1845
|
+
Logger.info(
|
|
1846
|
+
`[${this.name}] Junction table reindex check for ${modelName}/${docId}: found ${stringSetFieldNames.length} StringSet field(s) [${stringSetFieldNames.join(
|
|
1847
|
+
", "
|
|
1848
|
+
)}] with ${documentRecordCount} record(s) in document. ${shouldReindexJunctionTables ? "Re-indexing junction tables from document data." : "No records to re-index yet; ensuring tables exist."}`
|
|
1849
|
+
);
|
|
1850
|
+
for (const fieldName of stringSetFieldNames) {
|
|
1851
|
+
await db.createStringSetJunctionTable(modelName, fieldName);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
try {
|
|
1855
|
+
await db.deleteByDocumentId(modelName, docId);
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
Logger.error(
|
|
1858
|
+
`[${this.name}] Error clearing existing data for document ${docId} during initialization:`,
|
|
1859
|
+
error
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
if (verboseEnabled) {
|
|
1863
|
+
Logger.verbose(
|
|
1864
|
+
`[${this.name}] Loading initial data from document ${docId} YMap for ${modelName}...`
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
const extractStringSetValues = (value) => {
|
|
1868
|
+
if (!value) return [];
|
|
1869
|
+
if (value instanceof Y.Map) {
|
|
1870
|
+
return Array.from(value.entries()).filter(([, isMember]) => Boolean(isMember)).map(([key]) => key);
|
|
1871
|
+
}
|
|
1872
|
+
if (Array.isArray(value)) {
|
|
1873
|
+
return value.filter((v) => typeof v === "string");
|
|
1874
|
+
}
|
|
1875
|
+
if (typeof value === "object") {
|
|
1876
|
+
return Object.entries(value).filter(([, isMember]) => Boolean(isMember)).map(([key]) => key);
|
|
1877
|
+
}
|
|
1878
|
+
return [];
|
|
1879
|
+
};
|
|
1880
|
+
const junctionInsertCounts = {};
|
|
1881
|
+
await db.withTransaction(async (transactionalOps) => {
|
|
1882
|
+
for (const [recordId, recordData] of documentYMap.entries()) {
|
|
1883
|
+
if (!recordId) continue;
|
|
1884
|
+
let itemData;
|
|
1885
|
+
const stringSetValuesByField = {};
|
|
1886
|
+
if (recordData instanceof Y.Map) {
|
|
1887
|
+
itemData = {};
|
|
1888
|
+
const unknownFields = [];
|
|
1889
|
+
for (const [fieldKey, value] of recordData.entries()) {
|
|
1890
|
+
const fieldOptions = schema.fields.get(fieldKey);
|
|
1891
|
+
if (!fieldOptions) {
|
|
1892
|
+
unknownFields.push(fieldKey);
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
if (fieldOptions.type === "stringset") {
|
|
1896
|
+
stringSetValuesByField[fieldKey] = extractStringSetValues(value);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
if (value !== void 0) {
|
|
1900
|
+
itemData[fieldKey] = value;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (unknownFields.length > 0) {
|
|
1904
|
+
Logger.warn(
|
|
1905
|
+
`[${this.name}] Ignoring unknown fields [${unknownFields.join(
|
|
1906
|
+
", "
|
|
1907
|
+
)}] when loading record ${recordId} from document ${docId}`
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
} else {
|
|
1911
|
+
const legacyData = recordData;
|
|
1912
|
+
const filteredData = {};
|
|
1913
|
+
const unknownFields = [];
|
|
1914
|
+
for (const [fieldKey, value] of Object.entries(legacyData)) {
|
|
1915
|
+
const fieldOptions = schema.fields.get(fieldKey);
|
|
1916
|
+
if (!fieldOptions) {
|
|
1917
|
+
unknownFields.push(fieldKey);
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
if (fieldOptions.type === "stringset") {
|
|
1921
|
+
stringSetValuesByField[fieldKey] = extractStringSetValues(value);
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
if (value !== void 0) {
|
|
1925
|
+
filteredData[fieldKey] = value;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
if (unknownFields.length > 0) {
|
|
1929
|
+
Logger.warn(
|
|
1930
|
+
`[${this.name}] Ignoring unknown fields [${unknownFields.join(
|
|
1931
|
+
", "
|
|
1932
|
+
)}] when loading legacy record ${recordId} from document ${docId}`
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
itemData = filteredData;
|
|
1936
|
+
Logger.warn(
|
|
1937
|
+
`[${this.name}] Found legacy plain object during document initialization for ${recordId}`
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
if (!itemData.id) continue;
|
|
1941
|
+
try {
|
|
1942
|
+
await transactionalOps.insert(modelName, {
|
|
1943
|
+
...itemData,
|
|
1944
|
+
type: modelName,
|
|
1945
|
+
_meta_doc_id: docId,
|
|
1946
|
+
_meta_permission_hint: permissionHint
|
|
1947
|
+
});
|
|
1948
|
+
for (const [fieldName, values] of Object.entries(
|
|
1949
|
+
stringSetValuesByField
|
|
1950
|
+
)) {
|
|
1951
|
+
if (!values || values.length === 0) continue;
|
|
1952
|
+
try {
|
|
1953
|
+
await db.insertStringSetValues(
|
|
1954
|
+
modelName,
|
|
1955
|
+
fieldName,
|
|
1956
|
+
recordId,
|
|
1957
|
+
values
|
|
1958
|
+
);
|
|
1959
|
+
junctionInsertCounts[fieldName] = (junctionInsertCounts[fieldName] || 0) + values.length;
|
|
1960
|
+
} catch (stringSetError) {
|
|
1961
|
+
Logger.error(
|
|
1962
|
+
`[${this.name}] Error indexing StringSet field ${fieldName} for record ${recordId} in document ${docId}:`,
|
|
1963
|
+
stringSetError
|
|
1964
|
+
);
|
|
1965
|
+
throw stringSetError;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
if (error instanceof Error && error.message.includes("UNIQUE constraint")) {
|
|
1970
|
+
console.warn(
|
|
1971
|
+
`[${this.name}] Warning: Unique constraint conflict when loading ${recordId} from document ${docId}. This record already exists from another document. Data will still be indexed with document ID for disambiguation.`
|
|
1972
|
+
);
|
|
1973
|
+
} else {
|
|
1974
|
+
Logger.error(
|
|
1975
|
+
`[${this.name}] Error inserting item into db (document ${docId} load for ${modelName}):`,
|
|
1976
|
+
error,
|
|
1977
|
+
itemData
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
if (stringSetFieldNames.length > 0) {
|
|
1984
|
+
const totalInserted = Object.values(junctionInsertCounts).reduce(
|
|
1985
|
+
(sum, count) => sum + count,
|
|
1986
|
+
0
|
|
1987
|
+
);
|
|
1988
|
+
const perFieldSummary = Object.entries(junctionInsertCounts).map(([fieldName, count]) => `${fieldName}: ${count}`).join("; ");
|
|
1989
|
+
if (shouldReindexJunctionTables) {
|
|
1990
|
+
Logger.info(
|
|
1991
|
+
`[${this.name}] Junction table reindex summary for ${modelName}/${docId}: ${totalInserted} total value(s) processed${perFieldSummary ? ` (${perFieldSummary})` : ""}.`
|
|
1992
|
+
);
|
|
1993
|
+
} else {
|
|
1994
|
+
Logger.info(
|
|
1995
|
+
`[${this.name}] Junction table reindex summary for ${modelName}/${docId}: no StringSet values to index yet.`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
Logger.verbose(
|
|
2000
|
+
`[${this.name}] Setting up YMap observer for document ${docId}/${modelName}...`
|
|
2001
|
+
);
|
|
2002
|
+
const buildUniqueKey = (recordData, fields) => {
|
|
2003
|
+
const keyParts = [];
|
|
2004
|
+
for (const field of fields) {
|
|
2005
|
+
const value = recordData instanceof Y.Map ? recordData.get(field) : recordData[field];
|
|
2006
|
+
if (value === null || value === void 0) {
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
keyParts.push(String(value));
|
|
2010
|
+
}
|
|
2011
|
+
return keyParts.join("|");
|
|
2012
|
+
};
|
|
2013
|
+
const extractItemData = (key, recordData) => {
|
|
2014
|
+
let itemData;
|
|
2015
|
+
if (recordData instanceof Y.Map) {
|
|
2016
|
+
itemData = {};
|
|
2017
|
+
const unknownFields = [];
|
|
2018
|
+
for (const [fieldKey, value] of recordData.entries()) {
|
|
2019
|
+
const fieldOptions = schema.fields.get(fieldKey);
|
|
2020
|
+
if (!fieldOptions) {
|
|
2021
|
+
unknownFields.push(fieldKey);
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
if (fieldOptions.type === "stringset") {
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
if (value !== void 0) {
|
|
2028
|
+
itemData[fieldKey] = value;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (unknownFields.length > 0) {
|
|
2032
|
+
Logger.warn(
|
|
2033
|
+
`[${this.name}] Ignoring unknown fields [${unknownFields.join(
|
|
2034
|
+
", "
|
|
2035
|
+
)}] when syncing record ${key} from document ${docId}`
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
} else {
|
|
2039
|
+
const unknownFields = [];
|
|
2040
|
+
const filteredData = {};
|
|
2041
|
+
for (const [fieldKey, value] of Object.entries(recordData)) {
|
|
2042
|
+
const fieldOptions = schema.fields.get(fieldKey);
|
|
2043
|
+
if (!fieldOptions) {
|
|
2044
|
+
unknownFields.push(fieldKey);
|
|
2045
|
+
continue;
|
|
2046
|
+
}
|
|
2047
|
+
if (fieldOptions.type === "stringset") {
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
if (value !== void 0) {
|
|
2051
|
+
filteredData[fieldKey] = value;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
if (unknownFields.length > 0) {
|
|
2055
|
+
Logger.warn(
|
|
2056
|
+
`[${this.name}] Ignoring unknown fields [${unknownFields.join(
|
|
2057
|
+
", "
|
|
2058
|
+
)}] when syncing legacy record ${key} from document ${docId}`
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
itemData = filteredData;
|
|
2062
|
+
}
|
|
2063
|
+
if (!itemData.id) return null;
|
|
2064
|
+
return itemData;
|
|
2065
|
+
};
|
|
2066
|
+
const resolveConflictsForBatch = (candidateRecords) => {
|
|
2067
|
+
if (schema.resolvedUniqueConstraints.length === 0) {
|
|
2068
|
+
return /* @__PURE__ */ new Set();
|
|
2069
|
+
}
|
|
2070
|
+
const recordIdsToDiscard = /* @__PURE__ */ new Set();
|
|
2071
|
+
const recordIdsToKeep = /* @__PURE__ */ new Set();
|
|
2072
|
+
const allRecords = /* @__PURE__ */ new Map();
|
|
2073
|
+
for (const [recordId, recordData] of documentYMap.entries()) {
|
|
2074
|
+
if (recordData && !candidateRecords.has(recordId)) {
|
|
2075
|
+
allRecords.set(recordId, recordData);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
for (const [recordId, recordData] of candidateRecords.entries()) {
|
|
2079
|
+
allRecords.set(recordId, recordData);
|
|
2080
|
+
}
|
|
2081
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2082
|
+
const recordsByUniqueKey = /* @__PURE__ */ new Map();
|
|
2083
|
+
for (const [recordId, recordData] of allRecords.entries()) {
|
|
2084
|
+
if (recordIdsToDiscard.has(recordId)) continue;
|
|
2085
|
+
const uniqueKey = buildUniqueKey(recordData, constraint.fields);
|
|
2086
|
+
if (uniqueKey === null) continue;
|
|
2087
|
+
if (!recordsByUniqueKey.has(uniqueKey)) {
|
|
2088
|
+
recordsByUniqueKey.set(uniqueKey, []);
|
|
2089
|
+
}
|
|
2090
|
+
recordsByUniqueKey.get(uniqueKey).push(recordId);
|
|
2091
|
+
}
|
|
2092
|
+
for (const [uniqueKey, recordIds] of recordsByUniqueKey.entries()) {
|
|
2093
|
+
if (recordIds.length <= 1) continue;
|
|
2094
|
+
Logger.warn(
|
|
2095
|
+
`[${this.name}] CRDT conflict detected for unique constraint '${constraint.name}' on key '${uniqueKey.substring(0, 50)}${uniqueKey.length > 50 ? "..." : ""}': ${recordIds.length} records found.`
|
|
2096
|
+
);
|
|
2097
|
+
recordIds.sort();
|
|
2098
|
+
const idToKeep = recordIds[recordIds.length - 1];
|
|
2099
|
+
recordIdsToKeep.add(idToKeep);
|
|
2100
|
+
for (let i = 0; i < recordIds.length - 1; i++) {
|
|
2101
|
+
recordIdsToDiscard.add(recordIds[i]);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
for (const idToKeep of recordIdsToKeep) {
|
|
2106
|
+
recordIdsToDiscard.delete(idToKeep);
|
|
2107
|
+
}
|
|
2108
|
+
return recordIdsToDiscard;
|
|
2109
|
+
};
|
|
2110
|
+
documentYMap.observe(async (event) => {
|
|
2111
|
+
Logger.verbose(
|
|
2112
|
+
`[${this.name}] Document YMap change detected for ${modelName}/${docId}:`,
|
|
2113
|
+
event
|
|
2114
|
+
);
|
|
2115
|
+
const currentDbInstance = _BaseModel.dbInstance;
|
|
2116
|
+
if (!currentDbInstance) {
|
|
2117
|
+
Logger.error(
|
|
2118
|
+
`[${this.name}] DB instance not available for document YMap observer on ${modelName}/${docId}.`
|
|
2119
|
+
);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
const isRemoteChange = !event.transaction.local;
|
|
2123
|
+
const remoteAdds = /* @__PURE__ */ new Map();
|
|
2124
|
+
const localAddsAndUpdates = [];
|
|
2125
|
+
for (const [key, change] of event.changes.keys.entries()) {
|
|
2126
|
+
const recordData = documentYMap.get(key);
|
|
2127
|
+
if (change.action === "add" || change.action === "update") {
|
|
2128
|
+
if (!recordData || !key) continue;
|
|
2129
|
+
const itemData = extractItemData(key, recordData);
|
|
2130
|
+
if (!itemData) continue;
|
|
2131
|
+
if (change.action === "add" && recordData instanceof Y.Map) {
|
|
2132
|
+
Logger.verbose(
|
|
2133
|
+
`[${this.name}] Setting up observer on newly added nested YMap for record ${key} in document ${docId}`
|
|
2134
|
+
);
|
|
2135
|
+
this.setupNestedYMapObserverForDocument(
|
|
2136
|
+
key,
|
|
2137
|
+
recordData,
|
|
2138
|
+
docId,
|
|
2139
|
+
permissionHint
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
if (isRemoteChange && change.action === "add") {
|
|
2143
|
+
remoteAdds.set(key, { recordData, itemData });
|
|
2144
|
+
} else {
|
|
2145
|
+
localAddsAndUpdates.push({ key, action: change.action, recordData, itemData });
|
|
2146
|
+
}
|
|
2147
|
+
} else if (change.action === "delete") {
|
|
2148
|
+
Logger.verbose(
|
|
2149
|
+
`[${this.name}] Deleting item from db (${modelName}/${docId}):`,
|
|
2150
|
+
key
|
|
2151
|
+
);
|
|
2152
|
+
try {
|
|
2153
|
+
await currentDbInstance.delete(modelName, key);
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
Logger.error(
|
|
2156
|
+
`[${this.name}] Error deleting item from db (${modelName}/${docId}):`,
|
|
2157
|
+
error,
|
|
2158
|
+
key
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
for (const { itemData } of localAddsAndUpdates) {
|
|
2164
|
+
try {
|
|
2165
|
+
Logger.verbose(
|
|
2166
|
+
`[${this.name}] Syncing local item to db from document ${docId} (${modelName}):`,
|
|
2167
|
+
itemData
|
|
2168
|
+
);
|
|
2169
|
+
await currentDbInstance.insert(modelName, {
|
|
2170
|
+
...itemData,
|
|
2171
|
+
type: modelName,
|
|
2172
|
+
_meta_doc_id: docId,
|
|
2173
|
+
_meta_permission_hint: permissionHint
|
|
2174
|
+
});
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
Logger.error(
|
|
2177
|
+
`[${this.name}] Error syncing local item to db from document ${docId} (${modelName}):`,
|
|
2178
|
+
error,
|
|
2179
|
+
itemData
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (remoteAdds.size > 0) {
|
|
2184
|
+
Logger.verbose(
|
|
2185
|
+
`[${this.name}] Processing ${remoteAdds.size} remote add(s) with conflict resolution for ${modelName}/${docId}`
|
|
2186
|
+
);
|
|
2187
|
+
const candidateRecords = /* @__PURE__ */ new Map();
|
|
2188
|
+
for (const [key, { recordData }] of remoteAdds.entries()) {
|
|
2189
|
+
candidateRecords.set(key, recordData);
|
|
2190
|
+
}
|
|
2191
|
+
const idsToDiscard = resolveConflictsForBatch(candidateRecords);
|
|
2192
|
+
if (idsToDiscard.size > 0) {
|
|
2193
|
+
Logger.info(
|
|
2194
|
+
`[${this.name}] Discarding ${idsToDiscard.size} duplicate record(s) from remote sync for ${modelName}/${docId}: ${Array.from(idsToDiscard).join(", ")}`
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
for (const [key, { itemData }] of remoteAdds.entries()) {
|
|
2198
|
+
if (idsToDiscard.has(key)) {
|
|
2199
|
+
Logger.verbose(
|
|
2200
|
+
`[${this.name}] Skipping SQLite insert for discarded duplicate: ${key}`
|
|
2201
|
+
);
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
try {
|
|
2205
|
+
Logger.verbose(
|
|
2206
|
+
`[${this.name}] Syncing remote item to db from document ${docId} (${modelName}):`,
|
|
2207
|
+
itemData
|
|
2208
|
+
);
|
|
2209
|
+
await currentDbInstance.insert(modelName, {
|
|
2210
|
+
...itemData,
|
|
2211
|
+
type: modelName,
|
|
2212
|
+
_meta_doc_id: docId,
|
|
2213
|
+
_meta_permission_hint: permissionHint
|
|
2214
|
+
});
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
Logger.error(
|
|
2217
|
+
`[${this.name}] Error syncing remote item to db from document ${docId} (${modelName}):`,
|
|
2218
|
+
error,
|
|
2219
|
+
itemData
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
if (idsToDiscard.size > 0) {
|
|
2224
|
+
yDoc.transact(() => {
|
|
2225
|
+
for (const idToDiscard of idsToDiscard) {
|
|
2226
|
+
const recordData = documentYMap.get(idToDiscard);
|
|
2227
|
+
if (!recordData) continue;
|
|
2228
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2229
|
+
const uniqueKey = buildUniqueKey(recordData, constraint.fields);
|
|
2230
|
+
if (uniqueKey === null) continue;
|
|
2231
|
+
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
2232
|
+
const constraintMap = yDoc.getMap(constraintMapName);
|
|
2233
|
+
const currentIndexValue = constraintMap.get(uniqueKey);
|
|
2234
|
+
if (currentIndexValue === idToDiscard) {
|
|
2235
|
+
constraintMap.delete(uniqueKey);
|
|
2236
|
+
Logger.verbose(
|
|
2237
|
+
`[${this.name}] Removed discarded record ${idToDiscard} from unique index ${constraintMapName}`
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
documentYMap.delete(idToDiscard);
|
|
2242
|
+
Logger.verbose(
|
|
2243
|
+
`[${this.name}] Removed discarded record ${idToDiscard} from Y.Doc`
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
}, `conflict-resolution-${modelName}-${docId}`);
|
|
2247
|
+
yDoc.transact(() => {
|
|
2248
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2249
|
+
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
2250
|
+
const constraintMap = yDoc.getMap(constraintMapName);
|
|
2251
|
+
for (const [recordId, recordData] of documentYMap.entries()) {
|
|
2252
|
+
if (!recordData) continue;
|
|
2253
|
+
const uniqueKey = buildUniqueKey(recordData, constraint.fields);
|
|
2254
|
+
if (uniqueKey === null) continue;
|
|
2255
|
+
const currentIndexValue = constraintMap.get(uniqueKey);
|
|
2256
|
+
if (currentIndexValue !== recordId) {
|
|
2257
|
+
constraintMap.set(uniqueKey, recordId);
|
|
2258
|
+
Logger.verbose(
|
|
2259
|
+
`[${this.name}] Updated unique index ${constraintMapName}['${uniqueKey.substring(0, 30)}...'] to point to ${recordId}`
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}, `update-indexes-${modelName}-${docId}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
Logger.verbose(
|
|
2268
|
+
`[${this.name}] Document YMap observation transaction for ${modelName}/${docId} completed.`
|
|
2269
|
+
);
|
|
2270
|
+
this.notifyListeners();
|
|
2271
|
+
});
|
|
2272
|
+
Logger.verbose(
|
|
2273
|
+
`[${this.name}] Setting up observers on existing nested YMaps for ${modelName}/${docId}...`
|
|
2274
|
+
);
|
|
2275
|
+
for (const [recordId, recordData] of documentYMap.entries()) {
|
|
2276
|
+
if (recordData instanceof Y.Map) {
|
|
2277
|
+
Logger.verbose(
|
|
2278
|
+
`[${this.name}] Setting up observer on existing nested YMap for record ${recordId} in document ${docId}`
|
|
2279
|
+
);
|
|
2280
|
+
this.setupNestedYMapObserverForDocument(
|
|
2281
|
+
recordId,
|
|
2282
|
+
recordData,
|
|
2283
|
+
docId,
|
|
2284
|
+
permissionHint
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
Logger.verbose(
|
|
2289
|
+
`[${this.name}] Model ${modelName} initialization complete for document ${docId}.`
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
// New method to clean up data for a disconnected document
|
|
2293
|
+
static async cleanupDocumentData(docId) {
|
|
2294
|
+
const modelConstructor = this;
|
|
2295
|
+
const schema = modelConstructor.getSchema?.();
|
|
2296
|
+
if (!schema || !schema.options?.name) {
|
|
2297
|
+
Logger.warn(
|
|
2298
|
+
`[${this.name}] Cannot cleanup document data: Schema not found for model ${this.name}`
|
|
2299
|
+
);
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
const modelName = schema.options.name;
|
|
2303
|
+
Logger.verbose(
|
|
2304
|
+
`[${this.name}] Cleaning up data for document ${docId} from model ${modelName}...`
|
|
2305
|
+
);
|
|
2306
|
+
modelConstructor.connectedDocuments.delete(docId);
|
|
2307
|
+
modelConstructor.documentYMaps.delete(`${docId}_${modelName}`);
|
|
2308
|
+
Logger.verbose(
|
|
2309
|
+
`[${this.name}] Document ${docId} cleanup complete for model ${modelName}.`
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
static subscribe(callback) {
|
|
2313
|
+
const schema = this.getSchema?.();
|
|
2314
|
+
const modelTypeKey = schema?.options?.name || this.name.toLowerCase();
|
|
2315
|
+
if (!_BaseModel.listenersMap.has(modelTypeKey)) {
|
|
2316
|
+
_BaseModel.listenersMap.set(modelTypeKey, /* @__PURE__ */ new Set());
|
|
2317
|
+
}
|
|
2318
|
+
const listeners = _BaseModel.listenersMap.get(modelTypeKey);
|
|
2319
|
+
listeners.add(callback);
|
|
2320
|
+
return () => {
|
|
2321
|
+
listeners.delete(callback);
|
|
2322
|
+
if (listeners.size === 0) {
|
|
2323
|
+
_BaseModel.listenersMap.delete(modelTypeKey);
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
static notifyListeners() {
|
|
2328
|
+
const schema = this.getSchema?.();
|
|
2329
|
+
const modelTypeKey = schema?.options?.name || this.name.toLowerCase();
|
|
2330
|
+
const listeners = _BaseModel.listenersMap.get(modelTypeKey);
|
|
2331
|
+
if (!listeners) return;
|
|
2332
|
+
Logger.verbose(
|
|
2333
|
+
`[${this.name}] Notifying ${listeners.size} listeners for ${modelTypeKey}`
|
|
2334
|
+
);
|
|
2335
|
+
listeners.forEach((callback) => {
|
|
2336
|
+
try {
|
|
2337
|
+
callback();
|
|
2338
|
+
} catch (e) {
|
|
2339
|
+
Logger.error("Error in listener callback:", e);
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Legacy migration method - no longer needed in the new multidoc architecture.
|
|
2345
|
+
* Data migration is now handled during document initialization.
|
|
2346
|
+
*/
|
|
2347
|
+
static async migrateToNestedYMaps() {
|
|
2348
|
+
const schema = this.getSchema?.();
|
|
2349
|
+
if (!schema || !schema.options?.name) {
|
|
2350
|
+
throw new Error(
|
|
2351
|
+
`[${this.name}] Cannot migrate: Schema not properly initialized`
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
const modelName = schema.options.name;
|
|
2355
|
+
Logger.verbose(
|
|
2356
|
+
`[${modelName}] Migration method called but not needed in multidoc architecture`
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Utility to diff current instance data against YJS nested map data
|
|
2361
|
+
* Returns object with added, modified, and removed fields
|
|
2362
|
+
*/
|
|
2363
|
+
_diffWithYjsData() {
|
|
2364
|
+
const debugEnabled = Logger.getLogLevel() >= 4 /* DEBUG */;
|
|
2365
|
+
Logger.debug(
|
|
2366
|
+
`[_diffWithYjsData] Starting diff calculation for ID: ${this.id}`
|
|
2367
|
+
);
|
|
2368
|
+
Logger.debug(
|
|
2369
|
+
`[_diffWithYjsData] hasUnsavedChanges: ${this.hasUnsavedChanges}`
|
|
2370
|
+
);
|
|
2371
|
+
Logger.debug(`[_diffWithYjsData] _localChanges:`, this._localChanges);
|
|
2372
|
+
if (!this.hasUnsavedChanges) {
|
|
2373
|
+
Logger.debug(
|
|
2374
|
+
`[_diffWithYjsData] No unsaved changes, returning empty diff`
|
|
2375
|
+
);
|
|
2376
|
+
return { added: {}, modified: {}, removed: [] };
|
|
2377
|
+
}
|
|
2378
|
+
const modelConstructor = this.constructor;
|
|
2379
|
+
const schema = modelConstructor.getSchema();
|
|
2380
|
+
const modelName = schema.options.name;
|
|
2381
|
+
const docId = this._metaDocId;
|
|
2382
|
+
Logger.debug(`[_diffWithYjsData] modelName: ${modelName}, docId: ${docId}`);
|
|
2383
|
+
if (!docId) {
|
|
2384
|
+
throw new Error(
|
|
2385
|
+
`[${modelName}] Cannot diff with Y.js data: model instance has no document ID`
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2388
|
+
if (!modelConstructor.documentYMaps) {
|
|
2389
|
+
throw new Error(
|
|
2390
|
+
`[${modelName}] Multi-document system not initialized. documentYMaps is undefined.`
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
const documentYMap = modelConstructor.documentYMaps.get(
|
|
2394
|
+
`${docId}_${modelName}`
|
|
2395
|
+
);
|
|
2396
|
+
Logger.debug(`[_diffWithYjsData] documentYMap found: ${!!documentYMap}`);
|
|
2397
|
+
if (!documentYMap) {
|
|
2398
|
+
throw new Error(
|
|
2399
|
+
`[${modelName}] YMap not found for document '${docId}'. This should not happen.`
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
const recordYMap = documentYMap.get(this.id);
|
|
2403
|
+
Logger.debug(`[_diffWithYjsData] recordYMap found: ${!!recordYMap}`);
|
|
2404
|
+
const added = {};
|
|
2405
|
+
const modified = {};
|
|
2406
|
+
const removed = [];
|
|
2407
|
+
if (!recordYMap) {
|
|
2408
|
+
Logger.debug(
|
|
2409
|
+
`[_diffWithYjsData] No existing recordYMap, treating all local changes as 'added'`
|
|
2410
|
+
);
|
|
2411
|
+
if (this._localChanges) {
|
|
2412
|
+
for (const [key, value] of Object.entries(this._localChanges)) {
|
|
2413
|
+
Logger.debug(`[_diffWithYjsData] Adding field '${key}': ${value}`);
|
|
2414
|
+
if (value && value.type === "stringset") {
|
|
2415
|
+
const stringSetData = {};
|
|
2416
|
+
for (const addition of value.additions) {
|
|
2417
|
+
stringSetData[addition] = true;
|
|
2418
|
+
}
|
|
2419
|
+
added[key] = stringSetData;
|
|
2420
|
+
} else {
|
|
2421
|
+
added[key] = value;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
Logger.debug(`[_diffWithYjsData] Final diff for new record:`, {
|
|
2426
|
+
added,
|
|
2427
|
+
modified: {},
|
|
2428
|
+
removed: []
|
|
2429
|
+
});
|
|
2430
|
+
return { added, modified, removed: [] };
|
|
2431
|
+
}
|
|
2432
|
+
Logger.debug(
|
|
2433
|
+
`[_diffWithYjsData] Existing record found, comparing local changes with Y.js data`
|
|
2434
|
+
);
|
|
2435
|
+
if (debugEnabled) {
|
|
2436
|
+
Logger.debug(
|
|
2437
|
+
`[_diffWithYjsData] Existing recordYMap keys:`,
|
|
2438
|
+
Array.from(recordYMap.keys())
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
if (this._localChanges) {
|
|
2442
|
+
for (const [key, localValue] of Object.entries(this._localChanges)) {
|
|
2443
|
+
Logger.debug(
|
|
2444
|
+
`[_diffWithYjsData] Processing field '${key}' with local value: ${localValue}`
|
|
2445
|
+
);
|
|
2446
|
+
if (localValue && localValue.type === "stringset") {
|
|
2447
|
+
const currentYjsData = recordYMap.get(key) || {};
|
|
2448
|
+
const newStringSetData = {
|
|
2449
|
+
...currentYjsData
|
|
2450
|
+
};
|
|
2451
|
+
for (const addition of localValue.additions) {
|
|
2452
|
+
newStringSetData[addition] = true;
|
|
2453
|
+
}
|
|
2454
|
+
for (const removal of localValue.removals) {
|
|
2455
|
+
delete newStringSetData[removal];
|
|
2456
|
+
}
|
|
2457
|
+
const yjsValue = recordYMap.get(key);
|
|
2458
|
+
if (yjsValue === void 0) {
|
|
2459
|
+
added[key] = newStringSetData;
|
|
2460
|
+
} else if (!this._deepEqual(yjsValue, newStringSetData)) {
|
|
2461
|
+
modified[key] = newStringSetData;
|
|
2462
|
+
}
|
|
2463
|
+
} else {
|
|
2464
|
+
const yjsValue = recordYMap.get(key);
|
|
2465
|
+
Logger.debug(
|
|
2466
|
+
`[_diffWithYjsData] Field '${key}' - Y.js value: ${yjsValue}, local value: ${localValue}`
|
|
2467
|
+
);
|
|
2468
|
+
if (yjsValue === void 0) {
|
|
2469
|
+
Logger.debug(
|
|
2470
|
+
`[_diffWithYjsData] Field '${key}' not in Y.js, adding to 'added'`
|
|
2471
|
+
);
|
|
2472
|
+
added[key] = localValue;
|
|
2473
|
+
} else if (!this._deepEqual(yjsValue, localValue)) {
|
|
2474
|
+
Logger.debug(
|
|
2475
|
+
`[_diffWithYjsData] Field '${key}' different in Y.js, adding to 'modified'`
|
|
2476
|
+
);
|
|
2477
|
+
modified[key] = localValue;
|
|
2478
|
+
} else {
|
|
2479
|
+
Logger.debug(`[_diffWithYjsData] Field '${key}' unchanged`);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
Logger.debug(`[_diffWithYjsData] Final diff result:`, {
|
|
2485
|
+
added,
|
|
2486
|
+
modified,
|
|
2487
|
+
removed
|
|
2488
|
+
});
|
|
2489
|
+
Logger.verbose(`[${modelConstructor.name}] Diff for ${this.id}:`, {
|
|
2490
|
+
added: Object.keys(added),
|
|
2491
|
+
modified: Object.keys(modified),
|
|
2492
|
+
removed
|
|
2493
|
+
});
|
|
2494
|
+
return { added, modified, removed };
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Deep equality check for comparing field values
|
|
2498
|
+
*/
|
|
2499
|
+
_deepEqual(a, b) {
|
|
2500
|
+
if (a === b) return true;
|
|
2501
|
+
if (a == null || b == null) return a === b;
|
|
2502
|
+
if (typeof a !== typeof b) return false;
|
|
2503
|
+
if (typeof a !== "object") return false;
|
|
2504
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
2505
|
+
if (Array.isArray(a)) {
|
|
2506
|
+
if (a.length !== b.length) return false;
|
|
2507
|
+
for (let i = 0; i < a.length; i++) {
|
|
2508
|
+
if (!this._deepEqual(a[i], b[i])) return false;
|
|
2509
|
+
}
|
|
2510
|
+
return true;
|
|
2511
|
+
}
|
|
2512
|
+
const keysA = Object.keys(a);
|
|
2513
|
+
const keysB = Object.keys(b);
|
|
2514
|
+
if (keysA.length !== keysB.length) return false;
|
|
2515
|
+
for (const key of keysA) {
|
|
2516
|
+
if (!keysB.includes(key)) return false;
|
|
2517
|
+
if (!this._deepEqual(a[key], b[key])) return false;
|
|
2518
|
+
}
|
|
2519
|
+
return true;
|
|
2520
|
+
}
|
|
2521
|
+
static _buildKeyFromValues(fields, keyValues, modelName, constraintName) {
|
|
2522
|
+
if (fields.length === 0) return null;
|
|
2523
|
+
if (fields.length !== keyValues.length) {
|
|
2524
|
+
Logger.warn(
|
|
2525
|
+
`[${modelName}] Constraint '${constraintName}': Mismatch between number of fields (${fields.length}) and values (${keyValues.length}). Cannot build key.`
|
|
2526
|
+
);
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
if (keyValues.some((v) => v === null || v === void 0)) {
|
|
2530
|
+
return null;
|
|
2531
|
+
}
|
|
2532
|
+
if (fields.length === 1) {
|
|
2533
|
+
return String(keyValues[0]);
|
|
2534
|
+
} else {
|
|
2535
|
+
return JSON.stringify(keyValues);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
_buildUniqueKey(fields, data, modelName, constraintName) {
|
|
2539
|
+
const values = fields.map((f) => data[f]);
|
|
2540
|
+
return this.constructor._buildKeyFromValues(
|
|
2541
|
+
fields,
|
|
2542
|
+
values,
|
|
2543
|
+
modelName,
|
|
2544
|
+
constraintName
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
async save(options) {
|
|
2548
|
+
Logger.verbose(
|
|
2549
|
+
`[BaseModel.save()] Method entered. ID: ${this.id}, Model: ${this.constructor.modelName || this.constructor.name}. Timestamp: ${Date.now()}`
|
|
2550
|
+
);
|
|
2551
|
+
Logger.verbose(`[BaseModel.save()] Current 'this' object:`, this);
|
|
2552
|
+
Logger.verbose(`[BaseModel.save()] 'this.id' specifically:`, this.id);
|
|
2553
|
+
Logger.verbose(`[BaseModel.save()] Options:`, options);
|
|
2554
|
+
if (!this._isDirty) {
|
|
2555
|
+
Logger.verbose(
|
|
2556
|
+
`[${this.constructor.name}] No changes to save for ${this.id}`
|
|
2557
|
+
);
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
const modelConstructor = this.constructor;
|
|
2561
|
+
const schema = modelConstructor.getSchema();
|
|
2562
|
+
if (!schema || !schema.options || !schema.fields || !schema.resolvedUniqueConstraints) {
|
|
2563
|
+
throw new Error(
|
|
2564
|
+
`Schema (or resolvedUniqueConstraints) not found or invalid for model ${modelConstructor.name} in save.`
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
const modelName = schema.options.name;
|
|
2568
|
+
let targetDocId = null;
|
|
2569
|
+
let targetYDoc = null;
|
|
2570
|
+
let targetYMap = null;
|
|
2571
|
+
let permissionHint = null;
|
|
2572
|
+
const explicitDocId = options?.targetDocument || null;
|
|
2573
|
+
const instanceRememberedDocId = this._metaDocId;
|
|
2574
|
+
const modelDefaultDocId = this.constructor.getDocumentIdForModel(modelName);
|
|
2575
|
+
const globalDefaultDocId = this.constructor.getGlobalDefaultDocumentId();
|
|
2576
|
+
const precedence = [
|
|
2577
|
+
{ name: "explicit", value: explicitDocId },
|
|
2578
|
+
{ name: "remembered", value: instanceRememberedDocId },
|
|
2579
|
+
{ name: "modelDefault", value: modelDefaultDocId },
|
|
2580
|
+
{ name: "globalDefault", value: globalDefaultDocId }
|
|
2581
|
+
];
|
|
2582
|
+
for (const source of precedence) {
|
|
2583
|
+
if (source.value) {
|
|
2584
|
+
targetDocId = source.value;
|
|
2585
|
+
const documentInfo2 = this.constructor.connectedDocuments.get(targetDocId);
|
|
2586
|
+
if (!documentInfo2) {
|
|
2587
|
+
throw new DocumentClosedError(
|
|
2588
|
+
`[${modelName}] Document '${targetDocId}' from '${source.name}' is not connected. Please connect it or provide an open document id.`
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
break;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
if (!targetDocId) {
|
|
2595
|
+
throw new DocumentResolutionError(
|
|
2596
|
+
`[${modelName}] Unable to resolve a target document id (no explicit, no remembered, no model default, no global default).`
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
const documentInfo = modelConstructor.connectedDocuments.get(targetDocId);
|
|
2600
|
+
if (!documentInfo) {
|
|
2601
|
+
throw new DocumentClosedError(
|
|
2602
|
+
`[${modelName}] Document '${targetDocId}' is not connected. Please connect the document first.`
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
targetYDoc = documentInfo.yDoc;
|
|
2606
|
+
permissionHint = documentInfo.permissionHint;
|
|
2607
|
+
targetYMap = modelConstructor.documentYMaps.get(`${targetDocId}_${modelName}`) || null;
|
|
2608
|
+
if (!targetYMap) {
|
|
2609
|
+
throw new Error(
|
|
2610
|
+
`[${modelName}] YMap not found for document '${targetDocId}'. This should not happen.`
|
|
2611
|
+
);
|
|
2612
|
+
}
|
|
2613
|
+
if (permissionHint === "read" && !options?.forceWrite) {
|
|
2614
|
+
throw new Error(
|
|
2615
|
+
`[${modelName}] Cannot save to document '${targetDocId}' with read-only permission. Use forceWrite option to override.`
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
if (!targetYDoc) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`[${modelName}] Target Y.Doc not found for document '${targetDocId}'. Ensure document connection has completed.`
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
const yDoc = targetYDoc;
|
|
2624
|
+
if (!this.id) {
|
|
2625
|
+
Logger.error(
|
|
2626
|
+
"[BaseModel.save()] ID is undefined before saving! Instance dump:",
|
|
2627
|
+
JSON.stringify(this)
|
|
2628
|
+
);
|
|
2629
|
+
throw new Error("Cannot save item without an id. Ensure id is set.");
|
|
2630
|
+
}
|
|
2631
|
+
this.validateBeforeSave();
|
|
2632
|
+
Logger.debug(`[${modelName}.save] About to get current JS state`);
|
|
2633
|
+
Logger.debug(`[${modelName}.save] _localChanges:`, this._localChanges);
|
|
2634
|
+
Logger.debug(`[${modelName}.save] _isDirty:`, this._isDirty);
|
|
2635
|
+
const dataToSave = this.getCurrentJSState();
|
|
2636
|
+
Logger.debug(
|
|
2637
|
+
`[${modelName}.save] getCurrentJSState() returned:`,
|
|
2638
|
+
dataToSave
|
|
2639
|
+
);
|
|
2640
|
+
await yDoc.transact(async () => {
|
|
2641
|
+
if (!targetYMap) {
|
|
2642
|
+
throw new Error(
|
|
2643
|
+
`[${modelName}] Target YMap not found for save operation`
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
let recordYMap = targetYMap.get(this.id);
|
|
2647
|
+
const isUpdate = !!recordYMap;
|
|
2648
|
+
Logger.debug(`[${modelName}.save] isUpdate: ${isUpdate}`);
|
|
2649
|
+
Logger.debug(`[${modelName}.save] recordYMap exists: ${!!recordYMap}`);
|
|
2650
|
+
if (!recordYMap) {
|
|
2651
|
+
recordYMap = new Y.Map();
|
|
2652
|
+
Logger.verbose(
|
|
2653
|
+
`[${modelName}] Creating new nested YMap for record ${this.id} in document ${targetDocId || "legacy"}`
|
|
2654
|
+
);
|
|
2655
|
+
targetYMap.set(this.id, recordYMap);
|
|
2656
|
+
recordYMap.set("id", this.id);
|
|
2657
|
+
}
|
|
2658
|
+
if (!isUpdate || this._metaDocId && this._metaDocId !== targetDocId) {
|
|
2659
|
+
this._metaDocId = targetDocId;
|
|
2660
|
+
this._metaPermissionHint = permissionHint;
|
|
2661
|
+
Logger.debug(`[${modelName}.save] Set _metaDocId to: ${targetDocId}`);
|
|
2662
|
+
}
|
|
2663
|
+
let oldData = null;
|
|
2664
|
+
if (isUpdate) {
|
|
2665
|
+
oldData = {};
|
|
2666
|
+
for (const [key, value] of recordYMap.entries()) {
|
|
2667
|
+
oldData[key] = value;
|
|
2668
|
+
}
|
|
2669
|
+
Logger.verbose(
|
|
2670
|
+
`[${modelName}] Retrieved old data for update:`,
|
|
2671
|
+
oldData
|
|
2672
|
+
);
|
|
2673
|
+
Logger.debug(`[${modelName}.save] oldData:`, oldData);
|
|
2674
|
+
}
|
|
2675
|
+
Logger.debug(`[${modelName}.save] About to calculate diff`);
|
|
2676
|
+
const diff = this._diffWithYjsData();
|
|
2677
|
+
Logger.debug(`[${modelName}.save] Diff result:`, diff);
|
|
2678
|
+
const hasChanges = Object.keys(diff.added).length > 0 || Object.keys(diff.modified).length > 0 || diff.removed.length > 0;
|
|
2679
|
+
Logger.debug(`[${modelName}.save] hasChanges: ${hasChanges}`);
|
|
2680
|
+
if (!hasChanges && isUpdate) {
|
|
2681
|
+
Logger.verbose(
|
|
2682
|
+
`[${modelName}] No changes detected for ${this.id}, skipping save`
|
|
2683
|
+
);
|
|
2684
|
+
Logger.debug(`[${modelName}.save] No changes detected, skipping save`);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2688
|
+
const newUniqueKey = this._buildUniqueKey(
|
|
2689
|
+
constraint.fields,
|
|
2690
|
+
dataToSave,
|
|
2691
|
+
modelName,
|
|
2692
|
+
constraint.name
|
|
2693
|
+
);
|
|
2694
|
+
Logger.verbose(
|
|
2695
|
+
`[${modelName}] Save Check (Item '${this.id}'): Constraint '${constraint.name}', Built Key: '${newUniqueKey ? newUniqueKey.substring(0, 50) : null}'`
|
|
2696
|
+
);
|
|
2697
|
+
if (newUniqueKey === null) {
|
|
2698
|
+
Logger.verbose(
|
|
2699
|
+
`[${modelName}] Save Check (Item '${this.id}'): Constraint '${constraint.name}' skipped due to null key.`
|
|
2700
|
+
);
|
|
2701
|
+
continue;
|
|
2702
|
+
}
|
|
2703
|
+
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
2704
|
+
const constraintMap = yDoc.getMap(constraintMapName);
|
|
2705
|
+
Logger.verbose(
|
|
2706
|
+
`[${modelName}] Save Check (Item '${this.id}'): GET from '${constraintMapName}' with Key '${newUniqueKey.substring(
|
|
2707
|
+
0,
|
|
2708
|
+
50
|
|
2709
|
+
)}'`
|
|
2710
|
+
);
|
|
2711
|
+
const existingRecordIdWithNewKey = constraintMap.get(newUniqueKey);
|
|
2712
|
+
Logger.verbose(
|
|
2713
|
+
`[${modelName}] Save Check (Item '${this.id}'): Found Existing ID: '${existingRecordIdWithNewKey}' for Key '${newUniqueKey.substring(
|
|
2714
|
+
0,
|
|
2715
|
+
50
|
|
2716
|
+
)}'`
|
|
2717
|
+
);
|
|
2718
|
+
if (existingRecordIdWithNewKey && existingRecordIdWithNewKey !== this.id) {
|
|
2719
|
+
throw new UniqueConstraintViolationError(
|
|
2720
|
+
`Unique constraint '${constraint.name}' violated for model '${modelName}' on fields [${constraint.fields.join(
|
|
2721
|
+
", "
|
|
2722
|
+
)}]. Attempted ID: ${this.id}. Value(s) starting with '${newUniqueKey.substring(
|
|
2723
|
+
0,
|
|
2724
|
+
50
|
|
2725
|
+
)}...' already exist for record ID ${existingRecordIdWithNewKey}.`,
|
|
2726
|
+
modelName,
|
|
2727
|
+
constraint.name,
|
|
2728
|
+
constraint.fields,
|
|
2729
|
+
this.id,
|
|
2730
|
+
existingRecordIdWithNewKey
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
if (isUpdate && oldData) {
|
|
2735
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2736
|
+
const oldUniqueKey = this._buildUniqueKey(
|
|
2737
|
+
constraint.fields,
|
|
2738
|
+
oldData,
|
|
2739
|
+
modelName,
|
|
2740
|
+
constraint.name
|
|
2741
|
+
);
|
|
2742
|
+
const newUniqueKey = this._buildUniqueKey(
|
|
2743
|
+
constraint.fields,
|
|
2744
|
+
dataToSave,
|
|
2745
|
+
modelName,
|
|
2746
|
+
constraint.name
|
|
2747
|
+
);
|
|
2748
|
+
if (oldUniqueKey !== null && oldUniqueKey !== newUniqueKey) {
|
|
2749
|
+
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
2750
|
+
const constraintMap = yDoc.getMap(constraintMapName);
|
|
2751
|
+
Logger.verbose(
|
|
2752
|
+
`[${modelName}] Save (update): Deleting old unique key '${oldUniqueKey.substring(
|
|
2753
|
+
0,
|
|
2754
|
+
50
|
|
2755
|
+
)}...' for constraint '${constraint.name}', item ${this.id}`
|
|
2756
|
+
);
|
|
2757
|
+
constraintMap.delete(oldUniqueKey);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2762
|
+
const newUniqueKey = this._buildUniqueKey(
|
|
2763
|
+
constraint.fields,
|
|
2764
|
+
dataToSave,
|
|
2765
|
+
modelName,
|
|
2766
|
+
constraint.name
|
|
2767
|
+
);
|
|
2768
|
+
if (newUniqueKey !== null) {
|
|
2769
|
+
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
2770
|
+
const constraintMap = yDoc.getMap(constraintMapName);
|
|
2771
|
+
Logger.verbose(
|
|
2772
|
+
`[${modelName}] Save Set (Item '${this.id}'): SET into '${constraintMapName}' with Key '${newUniqueKey.substring(
|
|
2773
|
+
0,
|
|
2774
|
+
50
|
|
2775
|
+
)}', Value (Item ID) '${this.id}'`
|
|
2776
|
+
);
|
|
2777
|
+
constraintMap.set(newUniqueKey, this.id);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
Logger.debug(
|
|
2781
|
+
`[${modelName}.save] Applying field-level changes for ${this.id}`
|
|
2782
|
+
);
|
|
2783
|
+
Logger.debug(`[${modelName}.save] Diff result:`, diff);
|
|
2784
|
+
for (const [key, value] of Object.entries(diff.added)) {
|
|
2785
|
+
Logger.debug(`[${modelName}.save] Adding field '${key}':`, value);
|
|
2786
|
+
recordYMap.set(key, value);
|
|
2787
|
+
}
|
|
2788
|
+
for (const [key, value] of Object.entries(diff.modified)) {
|
|
2789
|
+
Logger.debug(`[${modelName}.save] Modifying field '${key}':`, value);
|
|
2790
|
+
recordYMap.set(key, value);
|
|
2791
|
+
}
|
|
2792
|
+
for (const key of diff.removed) {
|
|
2793
|
+
Logger.debug(`[${modelName}.save] Removing field '${key}'`);
|
|
2794
|
+
recordYMap.delete(key);
|
|
2795
|
+
}
|
|
2796
|
+
Logger.debug(
|
|
2797
|
+
`[${modelName}.save] After applying changes, recordYMap contents:`,
|
|
2798
|
+
Object.fromEntries(recordYMap.entries())
|
|
2799
|
+
);
|
|
2800
|
+
if (!isUpdate) {
|
|
2801
|
+
if (targetDocId && permissionHint) {
|
|
2802
|
+
modelConstructor.setupNestedYMapObserverForDocument(
|
|
2803
|
+
this.id,
|
|
2804
|
+
recordYMap,
|
|
2805
|
+
targetDocId,
|
|
2806
|
+
permissionHint
|
|
2807
|
+
);
|
|
2808
|
+
} else {
|
|
2809
|
+
modelConstructor.setupNestedYMapObserver(
|
|
2810
|
+
this.id,
|
|
2811
|
+
recordYMap
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
} else {
|
|
2815
|
+
if (!recordYMap.has("id")) {
|
|
2816
|
+
Logger.verbose(
|
|
2817
|
+
`[${modelName}] Adding id field '${this.id}' to existing nested YMap`
|
|
2818
|
+
);
|
|
2819
|
+
recordYMap.set("id", this.id);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
Logger.verbose(`[${modelName}] Save completed for ${this.id}`);
|
|
2823
|
+
if (_BaseModel.dbInstance) {
|
|
2824
|
+
Logger.verbose(
|
|
2825
|
+
`[${modelName}] Syncing StringSet changes to database for ${this.id}`
|
|
2826
|
+
);
|
|
2827
|
+
for (const [fieldName, localValue] of Object.entries(
|
|
2828
|
+
this._localChanges || {}
|
|
2829
|
+
)) {
|
|
2830
|
+
if (localValue && localValue.type === "stringset") {
|
|
2831
|
+
const fieldOptions = schema.fields.get(fieldName);
|
|
2832
|
+
if (fieldOptions?.type === "stringset") {
|
|
2833
|
+
if (localValue.additions.size > 0) {
|
|
2834
|
+
await _BaseModel.dbInstance.insertStringSetValues(
|
|
2835
|
+
modelName,
|
|
2836
|
+
fieldName,
|
|
2837
|
+
this.id,
|
|
2838
|
+
Array.from(localValue.additions)
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
if (localValue.removals.size > 0) {
|
|
2842
|
+
await _BaseModel.dbInstance.removeStringSetValues(
|
|
2843
|
+
modelName,
|
|
2844
|
+
fieldName,
|
|
2845
|
+
this.id,
|
|
2846
|
+
Array.from(localValue.removals)
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
Logger.verbose(
|
|
2853
|
+
`[${modelName}] StringSet database sync completed for ${this.id}`
|
|
2854
|
+
);
|
|
2855
|
+
}
|
|
2856
|
+
}, `save-record-${modelName}-${this.id}`);
|
|
2857
|
+
Logger.debug(
|
|
2858
|
+
`[${modelName}.save] About to clear local changes for ${this.id}`
|
|
2859
|
+
);
|
|
2860
|
+
Logger.debug(
|
|
2861
|
+
`[${modelName}.save] _localChanges before clear:`,
|
|
2862
|
+
this._localChanges
|
|
2863
|
+
);
|
|
2864
|
+
this.clearLocalChanges();
|
|
2865
|
+
Logger.debug(
|
|
2866
|
+
`[${modelName}.save] _localChanges after clear:`,
|
|
2867
|
+
this._localChanges
|
|
2868
|
+
);
|
|
2869
|
+
if (Logger.getLogLevel() >= 5 /* VERBOSE */) {
|
|
2870
|
+
Logger.verbose(`[${modelName}.save] Successfully saved ${this.id}`);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
async delete() {
|
|
2874
|
+
const modelConstructor = this.constructor;
|
|
2875
|
+
const schema = modelConstructor.getSchema();
|
|
2876
|
+
if (!schema || !schema.options || !schema.resolvedUniqueConstraints) {
|
|
2877
|
+
throw new Error(
|
|
2878
|
+
`Schema (or resolvedUniqueConstraints) not found or invalid for model ${modelConstructor.name} in delete.`
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
const modelName = schema.options.name;
|
|
2882
|
+
if (!this.id) {
|
|
2883
|
+
throw new Error("Cannot delete item without an id.");
|
|
2884
|
+
}
|
|
2885
|
+
let sourceYDoc = null;
|
|
2886
|
+
let sourceYMap = null;
|
|
2887
|
+
let docId = void 0;
|
|
2888
|
+
docId = this._metaDocId ?? void 0;
|
|
2889
|
+
const documentInfo = docId ? modelConstructor.connectedDocuments.get(docId) : void 0;
|
|
2890
|
+
if (!documentInfo) {
|
|
2891
|
+
throw new Error(
|
|
2892
|
+
`[${modelName}] Document '${docId}' is not connected. Cannot delete from disconnected document.`
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
sourceYDoc = documentInfo.yDoc;
|
|
2896
|
+
sourceYMap = modelConstructor.documentYMaps.get(`${docId}_${modelName}`) || null;
|
|
2897
|
+
if (!sourceYMap) {
|
|
2898
|
+
throw new Error(
|
|
2899
|
+
`[${modelName}] YMap not found for document '${docId}'. This should not happen.`
|
|
2900
|
+
);
|
|
2901
|
+
}
|
|
2902
|
+
const recordYMap = sourceYMap.get(this.id);
|
|
2903
|
+
if (!recordYMap) {
|
|
2904
|
+
Logger.warn(
|
|
2905
|
+
`[${modelName}] Delete: Item ${this.id} not found in YMap${docId ? ` for document ${docId}` : ""}. Cannot remove from unique constraint maps. Attempting main map deletion only.`
|
|
2906
|
+
);
|
|
2907
|
+
sourceYDoc.transact(() => {
|
|
2908
|
+
sourceYMap.delete(this.id);
|
|
2909
|
+
}, `delete-ghost-record-${modelName}-${this.id}`);
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
const dataToDelete = {};
|
|
2913
|
+
for (const [key, value] of recordYMap.entries()) {
|
|
2914
|
+
dataToDelete[key] = value;
|
|
2915
|
+
}
|
|
2916
|
+
await sourceYDoc.transact(async () => {
|
|
2917
|
+
for (const constraint of schema.resolvedUniqueConstraints) {
|
|
2918
|
+
const uniqueKey = this._buildUniqueKey(
|
|
2919
|
+
constraint.fields,
|
|
2920
|
+
dataToDelete,
|
|
2921
|
+
modelName,
|
|
2922
|
+
constraint.name
|
|
2923
|
+
);
|
|
2924
|
+
if (uniqueKey !== null) {
|
|
2925
|
+
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
2926
|
+
const constraintMap = sourceYDoc.getMap(constraintMapName);
|
|
2927
|
+
Logger.verbose(
|
|
2928
|
+
`[${modelName}] Delete: Removing unique key '${uniqueKey.substring(
|
|
2929
|
+
0,
|
|
2930
|
+
50
|
|
2931
|
+
)}...' for constraint '${constraint.name}', item ${this.id}${docId ? ` from document ${docId}` : ""}`
|
|
2932
|
+
);
|
|
2933
|
+
constraintMap.delete(uniqueKey);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
Logger.verbose(
|
|
2937
|
+
`[${modelName}] Deleting nested YMap from main YMap: ${this.id}${docId ? ` from document ${docId}` : ""}`
|
|
2938
|
+
);
|
|
2939
|
+
sourceYMap.delete(this.id);
|
|
2940
|
+
}, `delete-record-${modelName}-${this.id}`);
|
|
2941
|
+
}
|
|
2942
|
+
// Get current state combining local changes with Yjs data
|
|
2943
|
+
getCurrentJSState() {
|
|
2944
|
+
const schema = this.constructor.getSchema();
|
|
2945
|
+
if (!schema || !schema.fields) {
|
|
2946
|
+
throw new Error(
|
|
2947
|
+
`Schema not found or invalid for model ${this.constructor.name} in getCurrentJSState.`
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2950
|
+
const result = {};
|
|
2951
|
+
Logger.debug(
|
|
2952
|
+
`[getCurrentJSState] Starting for model ${this.constructor.name}, ID: ${this.id}`
|
|
2953
|
+
);
|
|
2954
|
+
for (const fieldKey of schema.fields.keys()) {
|
|
2955
|
+
const value = this.getValue(fieldKey);
|
|
2956
|
+
if (value !== void 0) {
|
|
2957
|
+
const fieldOptions = schema.fields.get(fieldKey);
|
|
2958
|
+
if (fieldOptions?.type === "stringset" && value instanceof StringSet) {
|
|
2959
|
+
result[fieldKey] = value.toArray();
|
|
2960
|
+
} else {
|
|
2961
|
+
result[fieldKey] = value;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (schema.options?.name) {
|
|
2966
|
+
result.type = schema.options.name;
|
|
2967
|
+
}
|
|
2968
|
+
if (this.id !== void 0) {
|
|
2969
|
+
result.id = this.id;
|
|
2970
|
+
}
|
|
2971
|
+
if (this._metaDocId !== null) {
|
|
2972
|
+
result._meta_doc_id = this._metaDocId;
|
|
2973
|
+
}
|
|
2974
|
+
if (this._metaPermissionHint !== null) {
|
|
2975
|
+
result._meta_permission_hint = this._metaPermissionHint;
|
|
2976
|
+
}
|
|
2977
|
+
return result;
|
|
2978
|
+
}
|
|
2979
|
+
// Keep toJSON for backward compatibility, but delegate to getCurrentJSState
|
|
2980
|
+
toJSON() {
|
|
2981
|
+
return this.getCurrentJSState();
|
|
2982
|
+
}
|
|
2983
|
+
static async find(id) {
|
|
2984
|
+
const schema = this.getSchema?.();
|
|
2985
|
+
if (!schema || !schema.options.name)
|
|
2986
|
+
throw new Error("Model not properly initialized for find");
|
|
2987
|
+
const modelName = schema.options.name;
|
|
2988
|
+
Logger.verbose(`[${modelName}] Finding item by id:`, id);
|
|
2989
|
+
const modelConstructor = this;
|
|
2990
|
+
const debugEnabled = Logger.getLogLevel() >= 4 /* DEBUG */;
|
|
2991
|
+
Logger.debug(`[${modelName}.find] Using multi-document system`);
|
|
2992
|
+
if (debugEnabled) {
|
|
2993
|
+
Logger.debug(
|
|
2994
|
+
`[${modelName}.find] Connected documents: [${Array.from(
|
|
2995
|
+
modelConstructor.connectedDocuments.keys()
|
|
2996
|
+
).join(", ")}]`
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
for (const [docId] of modelConstructor.connectedDocuments) {
|
|
3000
|
+
Logger.debug(`[${modelName}.find] Searching in document: ${docId}`);
|
|
3001
|
+
const documentYMapKey = `${docId}_${modelName}`;
|
|
3002
|
+
Logger.debug(
|
|
3003
|
+
`[${modelName}.find] Looking for documentYMap with key: ${documentYMapKey}`
|
|
3004
|
+
);
|
|
3005
|
+
const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
|
|
3006
|
+
Logger.debug(`[${modelName}.find] DocumentYMap found: ${!!documentYMap}`);
|
|
3007
|
+
if (documentYMap) {
|
|
3008
|
+
if (debugEnabled) {
|
|
3009
|
+
Logger.debug(
|
|
3010
|
+
`[${modelName}.find] DocumentYMap keys: [${Array.from(
|
|
3011
|
+
documentYMap.keys()
|
|
3012
|
+
).join(", ")}]`
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
const recordYMap = documentYMap.get(id);
|
|
3016
|
+
Logger.debug(
|
|
3017
|
+
`[${modelName}.find] RecordYMap found for ID '${id}': ${!!recordYMap}`
|
|
3018
|
+
);
|
|
3019
|
+
if (recordYMap) {
|
|
3020
|
+
if (debugEnabled) {
|
|
3021
|
+
Logger.debug(
|
|
3022
|
+
`[${modelName}.find] RecordYMap keys: [${Array.from(
|
|
3023
|
+
recordYMap.keys()
|
|
3024
|
+
).join(", ")}]`
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
3027
|
+
Logger.debug(`[${modelName}.find] Creating instance with ID: ${id}`);
|
|
3028
|
+
const instance = new this({ id });
|
|
3029
|
+
Logger.debug(`[${modelName}.find] Setting _metaDocId to: ${docId}`);
|
|
3030
|
+
instance._metaDocId = docId;
|
|
3031
|
+
const connectedDoc = modelConstructor.connectedDocuments.get(docId);
|
|
3032
|
+
if (connectedDoc) {
|
|
3033
|
+
Logger.debug(
|
|
3034
|
+
`[${modelName}.find] Setting _metaPermissionHint to: ${connectedDoc.permissionHint}`
|
|
3035
|
+
);
|
|
3036
|
+
instance._metaPermissionHint = connectedDoc.permissionHint;
|
|
3037
|
+
}
|
|
3038
|
+
Logger.debug(
|
|
3039
|
+
`[${modelName}.find] Returning instance with _metaDocId: ${instance._metaDocId}`
|
|
3040
|
+
);
|
|
3041
|
+
return instance;
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
return null;
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Document-style query API - returns paginated results
|
|
3049
|
+
*/
|
|
3050
|
+
static async query(filter = {}, options) {
|
|
3051
|
+
if (!_BaseModel.dbInstance) {
|
|
3052
|
+
const modelNameForError = this.modelName || this.name || "BaseModel";
|
|
3053
|
+
throw new Error(
|
|
3054
|
+
`[${modelNameForError}] Database not initialized for query. Connect at least one document via initJsBao(...).connectDocument or call initializeForDocument(yDoc, db, docId, permissionHint) before running queries.`
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
const schema = this.getSchema?.();
|
|
3058
|
+
if (!schema || !schema.options?.name) {
|
|
3059
|
+
throw new Error("Model not properly initialized for query");
|
|
3060
|
+
}
|
|
3061
|
+
const modelName = schema.options.name;
|
|
3062
|
+
Logger.verbose(`[${modelName}] Executing query:`, { filter, options });
|
|
3063
|
+
const hasIncludes = options?.include && options.include.length > 0;
|
|
3064
|
+
const translatorOptions = hasIncludes && options?.projection ? { ...options, projection: void 0 } : options;
|
|
3065
|
+
const translator = new DocumentQueryTranslator(modelName, schema.fields);
|
|
3066
|
+
const translatedQuery = translator.translateFind(filter, translatorOptions);
|
|
3067
|
+
Logger.verbose(`[${modelName}] Translated SQL:`, translatedQuery.sql);
|
|
3068
|
+
Logger.verbose(`[${modelName}] SQL params:`, translatedQuery.params);
|
|
3069
|
+
const results = await _BaseModel.dbInstance.query(
|
|
3070
|
+
translatedQuery.sql,
|
|
3071
|
+
translatedQuery.params
|
|
3072
|
+
);
|
|
3073
|
+
Logger.verbose(`[${modelName}] Query returned ${results.length} results`);
|
|
3074
|
+
if (options?.include && options.include.length > 0) {
|
|
3075
|
+
const { IncludeResolver } = await import("./IncludeResolver-WGSQDMS7.js");
|
|
3076
|
+
const resolver = new IncludeResolver(_BaseModel.dbInstance);
|
|
3077
|
+
await resolver.resolve(results, options.include, 0, modelName);
|
|
3078
|
+
}
|
|
3079
|
+
const requestedLimit = options?.limit;
|
|
3080
|
+
const hasMore = CursorManager.hasMoreResults(
|
|
3081
|
+
requestedLimit,
|
|
3082
|
+
results.length
|
|
3083
|
+
);
|
|
3084
|
+
const isFirstPage = !options?.uniqueStartKey;
|
|
3085
|
+
const cursors = CursorManager.generateResultCursors(
|
|
3086
|
+
results,
|
|
3087
|
+
translatedQuery.sortFields,
|
|
3088
|
+
options?.direction || 1,
|
|
3089
|
+
hasMore,
|
|
3090
|
+
isFirstPage
|
|
3091
|
+
);
|
|
3092
|
+
let transformedData;
|
|
3093
|
+
if (options?.projection && Object.keys(options.projection).length > 0) {
|
|
3094
|
+
transformedData = results.map((row) => {
|
|
3095
|
+
const projected = {};
|
|
3096
|
+
if (row.hasOwnProperty("id")) {
|
|
3097
|
+
projected.id = row.id;
|
|
3098
|
+
}
|
|
3099
|
+
for (const [field, include] of Object.entries(options.projection)) {
|
|
3100
|
+
if (include === 1 && row.hasOwnProperty(field)) {
|
|
3101
|
+
projected[field] = row[field];
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
if (row._related) projected._related = row._related;
|
|
3105
|
+
return projected;
|
|
3106
|
+
});
|
|
3107
|
+
} else {
|
|
3108
|
+
const modelConstructor = this;
|
|
3109
|
+
const documentYMaps = modelConstructor.documentYMaps;
|
|
3110
|
+
const connectedDocuments = modelConstructor.connectedDocuments;
|
|
3111
|
+
transformedData = results.map((data) => {
|
|
3112
|
+
let foundRecordYMap;
|
|
3113
|
+
let foundDocId;
|
|
3114
|
+
for (const docId of connectedDocuments.keys()) {
|
|
3115
|
+
const documentYMapKey = `${docId}_${modelName}`;
|
|
3116
|
+
const documentYMap = documentYMaps.get(documentYMapKey);
|
|
3117
|
+
if (documentYMap) {
|
|
3118
|
+
const recordYMap = documentYMap.get(data.id);
|
|
3119
|
+
if (recordYMap) {
|
|
3120
|
+
foundRecordYMap = recordYMap;
|
|
3121
|
+
foundDocId = docId;
|
|
3122
|
+
break;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
if (!foundRecordYMap || !foundDocId) {
|
|
3127
|
+
Logger.warn(
|
|
3128
|
+
`[${modelName}] Query result item ID ${data.id} not found in any connected document YMap. DB might be out of sync.`
|
|
3129
|
+
);
|
|
3130
|
+
return null;
|
|
3131
|
+
}
|
|
3132
|
+
const instance = new this({ id: data.id });
|
|
3133
|
+
if (instance && typeof instance === "object" && instance instanceof _BaseModel) {
|
|
3134
|
+
instance._metaDocId = foundDocId;
|
|
3135
|
+
instance._metaPermissionHint = "read-write";
|
|
3136
|
+
if (data._related) instance._related = data._related;
|
|
3137
|
+
}
|
|
3138
|
+
return instance;
|
|
3139
|
+
}).filter(Boolean);
|
|
3140
|
+
}
|
|
3141
|
+
return {
|
|
3142
|
+
data: transformedData,
|
|
3143
|
+
nextCursor: cursors.nextCursor,
|
|
3144
|
+
prevCursor: cursors.prevCursor,
|
|
3145
|
+
hasMore
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Main aggregation API - performs grouping, faceting, and statistical operations
|
|
3150
|
+
* @param options Aggregation configuration with groupBy, operations, filter, limit, and sort
|
|
3151
|
+
* @returns Nested object structure with aggregation results
|
|
3152
|
+
*
|
|
3153
|
+
* @example
|
|
3154
|
+
* // Simple facet count
|
|
3155
|
+
* const tagCounts = await Model.aggregate({
|
|
3156
|
+
* groupBy: ['tags'],
|
|
3157
|
+
* operations: [{ type: 'count' }]
|
|
3158
|
+
* });
|
|
3159
|
+
* // Result: { red: 15, blue: 8, green: 12 }
|
|
3160
|
+
*
|
|
3161
|
+
* @example
|
|
3162
|
+
* // Multi-dimensional grouping with multiple operations
|
|
3163
|
+
* const categoryStats = await Model.aggregate({
|
|
3164
|
+
* groupBy: ['category', 'status'],
|
|
3165
|
+
* operations: [
|
|
3166
|
+
* { type: 'count' },
|
|
3167
|
+
* { type: 'sum', field: 'amount' },
|
|
3168
|
+
* { type: 'avg', field: 'score' }
|
|
3169
|
+
* ],
|
|
3170
|
+
* filter: { active: true },
|
|
3171
|
+
* sort: { field: 'count', direction: 'desc' },
|
|
3172
|
+
* limit: 10
|
|
3173
|
+
* });
|
|
3174
|
+
*
|
|
3175
|
+
* @example
|
|
3176
|
+
* // StringSet membership grouping
|
|
3177
|
+
* const urgentCounts = await Model.aggregate({
|
|
3178
|
+
* groupBy: [{ field: 'tags', contains: 'urgent' }],
|
|
3179
|
+
* operations: [{ type: 'count' }]
|
|
3180
|
+
* });
|
|
3181
|
+
* // Result: { true: 5, false: 23 }
|
|
3182
|
+
*/
|
|
3183
|
+
static async aggregate(options) {
|
|
3184
|
+
if (!_BaseModel.dbInstance) {
|
|
3185
|
+
const modelNameForError = this.modelName || this.name || "BaseModel";
|
|
3186
|
+
throw new Error(
|
|
3187
|
+
`[${modelNameForError}] Database not initialized for aggregation. Connect at least one document via initJsBao(...).connectDocument or call initializeForDocument(yDoc, db, docId, permissionHint) before running aggregations.`
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
3190
|
+
const schema = this.getSchema?.();
|
|
3191
|
+
if (!schema || !schema.options?.name) {
|
|
3192
|
+
throw new Error("Model not properly initialized for aggregation");
|
|
3193
|
+
}
|
|
3194
|
+
const modelName = schema.options.name;
|
|
3195
|
+
Logger.verbose(`[${modelName}] Executing structured aggregation:`, options);
|
|
3196
|
+
for (const operation of options.operations) {
|
|
3197
|
+
if (operation.type !== "count" && !operation.field) {
|
|
3198
|
+
throw new Error(
|
|
3199
|
+
`Operation '${operation.type}' requires a field parameter`
|
|
3200
|
+
);
|
|
3201
|
+
}
|
|
3202
|
+
if (operation.type === "count" && operation.field) {
|
|
3203
|
+
throw new Error(`Operation 'count' should not have a field parameter`);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
for (const groupBy of options.groupBy) {
|
|
3207
|
+
if (typeof groupBy === "string") {
|
|
3208
|
+
if (!schema.fields.has(groupBy)) {
|
|
3209
|
+
throw new Error(`Unknown field '${groupBy}' in groupBy`);
|
|
3210
|
+
}
|
|
3211
|
+
} else {
|
|
3212
|
+
if (!schema.fields.has(groupBy.field)) {
|
|
3213
|
+
throw new Error(
|
|
3214
|
+
`Unknown field '${groupBy.field}' in StringSet membership groupBy`
|
|
3215
|
+
);
|
|
3216
|
+
}
|
|
3217
|
+
const fieldOptions = schema.fields.get(groupBy.field);
|
|
3218
|
+
if (fieldOptions?.type !== "stringset") {
|
|
3219
|
+
throw new Error(
|
|
3220
|
+
`Field '${groupBy.field}' is not a StringSet field but used in membership groupBy`
|
|
3221
|
+
);
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
const aggregationQuery = this.buildAggregationQuery(
|
|
3226
|
+
options,
|
|
3227
|
+
schema,
|
|
3228
|
+
modelName
|
|
3229
|
+
);
|
|
3230
|
+
Logger.verbose(
|
|
3231
|
+
`[${modelName}] Generated aggregation SQL:`,
|
|
3232
|
+
aggregationQuery.sql
|
|
3233
|
+
);
|
|
3234
|
+
Logger.verbose(
|
|
3235
|
+
`[${modelName}] Aggregation params:`,
|
|
3236
|
+
aggregationQuery.params
|
|
3237
|
+
);
|
|
3238
|
+
const results = await _BaseModel.dbInstance.query(
|
|
3239
|
+
aggregationQuery.sql,
|
|
3240
|
+
aggregationQuery.params
|
|
3241
|
+
);
|
|
3242
|
+
Logger.verbose(
|
|
3243
|
+
`[${modelName}] Aggregation returned ${results.length} results`
|
|
3244
|
+
);
|
|
3245
|
+
const processedResult = this.processAggregationResults(
|
|
3246
|
+
results,
|
|
3247
|
+
options,
|
|
3248
|
+
aggregationQuery.aliasMetadata
|
|
3249
|
+
);
|
|
3250
|
+
if (aggregationQuery.aliasMetadata && aggregationQuery.aliasMetadata.length > 0) {
|
|
3251
|
+
const debugMap = {};
|
|
3252
|
+
for (const detail of aggregationQuery.aliasMetadata) {
|
|
3253
|
+
debugMap[detail.alias] = {
|
|
3254
|
+
field: detail.field,
|
|
3255
|
+
contains: detail.contains,
|
|
3256
|
+
originalAlias: detail.originalAlias
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
Object.defineProperty(processedResult, "aliasDebugMap", {
|
|
3260
|
+
value: debugMap,
|
|
3261
|
+
enumerable: false,
|
|
3262
|
+
configurable: false,
|
|
3263
|
+
writable: false
|
|
3264
|
+
});
|
|
3265
|
+
}
|
|
3266
|
+
return processedResult;
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Build SQL query for structured aggregation
|
|
3270
|
+
*/
|
|
3271
|
+
static buildAggregationQuery(options, schema, modelName) {
|
|
3272
|
+
const translator = new DocumentQueryTranslator(modelName, schema.fields);
|
|
3273
|
+
const regularGroupBy = [];
|
|
3274
|
+
const stringSetFacets = [];
|
|
3275
|
+
const stringSetMemberships = [];
|
|
3276
|
+
for (const groupBy of options.groupBy) {
|
|
3277
|
+
if (typeof groupBy === "string") {
|
|
3278
|
+
const fieldOptions = schema.fields.get(groupBy);
|
|
3279
|
+
if (fieldOptions?.type === "stringset") {
|
|
3280
|
+
stringSetFacets.push(groupBy);
|
|
3281
|
+
} else {
|
|
3282
|
+
regularGroupBy.push(groupBy);
|
|
3283
|
+
}
|
|
3284
|
+
} else {
|
|
3285
|
+
stringSetMemberships.push(groupBy);
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
if (stringSetFacets.length > 0 && regularGroupBy.length === 0 && stringSetMemberships.length === 0) {
|
|
3289
|
+
return this.buildStringSetFacetQuery(
|
|
3290
|
+
stringSetFacets,
|
|
3291
|
+
options,
|
|
3292
|
+
translator,
|
|
3293
|
+
modelName
|
|
3294
|
+
);
|
|
3295
|
+
} else if (stringSetMemberships.length > 0 || regularGroupBy.length > 0) {
|
|
3296
|
+
return this.buildRegularAggregationQuery(
|
|
3297
|
+
regularGroupBy,
|
|
3298
|
+
stringSetMemberships,
|
|
3299
|
+
options,
|
|
3300
|
+
translator,
|
|
3301
|
+
modelName
|
|
3302
|
+
);
|
|
3303
|
+
} else {
|
|
3304
|
+
throw new Error("Invalid aggregation configuration");
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
/**
|
|
3308
|
+
* Get the proper database table name (should match database engine naming)
|
|
3309
|
+
*/
|
|
3310
|
+
static getDatabaseTableName(modelName) {
|
|
3311
|
+
return `model_${modelName.toLowerCase()}`;
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* Get the proper database junction table name for StringSet fields
|
|
3315
|
+
*/
|
|
3316
|
+
static getDatabaseJunctionTableName(modelName, fieldName) {
|
|
3317
|
+
return `${this.getDatabaseTableName(modelName)}_${fieldName}`;
|
|
3318
|
+
}
|
|
3319
|
+
static buildMembershipKey(field, contains) {
|
|
3320
|
+
return `${field}::${contains}`;
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* Build query for StringSet facet counts
|
|
3324
|
+
*/
|
|
3325
|
+
static buildStringSetFacetQuery(stringSetFields, options, translator, modelName) {
|
|
3326
|
+
if (stringSetFields.length > 1) {
|
|
3327
|
+
throw new Error(
|
|
3328
|
+
"Multiple StringSet facet fields not supported in single aggregation"
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
const fieldName = stringSetFields[0];
|
|
3332
|
+
const junctionTable = this.getDatabaseJunctionTableName(
|
|
3333
|
+
modelName,
|
|
3334
|
+
fieldName
|
|
3335
|
+
);
|
|
3336
|
+
const quotedJunctionTable = quoteIdentifier(junctionTable);
|
|
3337
|
+
const mainTable = this.getDatabaseTableName(modelName);
|
|
3338
|
+
const quotedMainTable = quoteIdentifier(mainTable);
|
|
3339
|
+
const recordIdColumn = `${modelName.toLowerCase()}_id`;
|
|
3340
|
+
const quotedRecordIdColumn = quoteIdentifier(recordIdColumn);
|
|
3341
|
+
const quotedValueColumn = quoteIdentifier("value");
|
|
3342
|
+
const groupKeyAlias = quoteIdentifier("group_key");
|
|
3343
|
+
let sql = `
|
|
3344
|
+
SELECT
|
|
3345
|
+
${quotedJunctionTable}.${quotedValueColumn} AS ${groupKeyAlias},
|
|
3346
|
+
COUNT(*) as count
|
|
3347
|
+
FROM ${quotedJunctionTable}
|
|
3348
|
+
INNER JOIN ${quotedMainTable} ON ${quotedJunctionTable}.${quotedRecordIdColumn} = ${quotedMainTable}.${quoteIdentifier(
|
|
3349
|
+
"id"
|
|
3350
|
+
)}
|
|
3351
|
+
`;
|
|
3352
|
+
let params = [];
|
|
3353
|
+
if (options.filter && Object.keys(options.filter).length > 0) {
|
|
3354
|
+
const whereClause = translator.translateFind(options.filter, {});
|
|
3355
|
+
const whereMatch = whereClause.sql.match(
|
|
3356
|
+
/WHERE\s+(.+?)(?:\s+ORDER\s+BY|\s+LIMIT|\s*$)/i
|
|
3357
|
+
);
|
|
3358
|
+
if (whereMatch && whereMatch[1]) {
|
|
3359
|
+
sql += ` WHERE ${whereMatch[1]}`;
|
|
3360
|
+
params = whereClause.params;
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
sql += ` GROUP BY ${quotedJunctionTable}.${quotedValueColumn}`;
|
|
3364
|
+
if (options.sort) {
|
|
3365
|
+
const sortField = options.sort.field === "count" ? "COUNT(*)" : quoteIdentifier(options.sort.field);
|
|
3366
|
+
const sqlDirection = options.sort.direction === 1 ? "ASC" : "DESC";
|
|
3367
|
+
sql += ` ORDER BY ${sortField} ${sqlDirection}`;
|
|
3368
|
+
}
|
|
3369
|
+
if (options.limit) {
|
|
3370
|
+
sql += ` LIMIT ${options.limit}`;
|
|
3371
|
+
}
|
|
3372
|
+
return { sql, params };
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Build query for regular field aggregation
|
|
3376
|
+
*/
|
|
3377
|
+
static buildRegularAggregationQuery(regularGroupBy, stringSetMemberships, options, translator, modelName) {
|
|
3378
|
+
const mainTable = this.getDatabaseTableName(modelName);
|
|
3379
|
+
const quotedMainTable = quoteIdentifier(mainTable);
|
|
3380
|
+
const recordIdColumn = `${modelName.toLowerCase()}_id`;
|
|
3381
|
+
const quotedRecordIdColumn = quoteIdentifier(recordIdColumn);
|
|
3382
|
+
const quotedIdColumn = quoteIdentifier("id");
|
|
3383
|
+
const quotedValueColumn = quoteIdentifier("value");
|
|
3384
|
+
const selectParts = [];
|
|
3385
|
+
const aliasMetadata = [];
|
|
3386
|
+
const usedAliases = /* @__PURE__ */ new Set();
|
|
3387
|
+
for (const field of regularGroupBy) {
|
|
3388
|
+
selectParts.push(`${quotedMainTable}.${quoteIdentifier(field)}`);
|
|
3389
|
+
}
|
|
3390
|
+
for (const membership of stringSetMemberships) {
|
|
3391
|
+
const membershipKey = this.buildMembershipKey(
|
|
3392
|
+
membership.field,
|
|
3393
|
+
membership.contains
|
|
3394
|
+
);
|
|
3395
|
+
const originalAlias = `has_${membership.field}_${membership.contains}`;
|
|
3396
|
+
let aliasPrefix = `has_${membership.field}`;
|
|
3397
|
+
let alias = buildSafeAlias(aliasPrefix, membershipKey);
|
|
3398
|
+
let counter = 1;
|
|
3399
|
+
while (usedAliases.has(alias)) {
|
|
3400
|
+
alias = buildSafeAlias(`${aliasPrefix}_${counter++}`, membershipKey);
|
|
3401
|
+
}
|
|
3402
|
+
usedAliases.add(alias);
|
|
3403
|
+
const quotedAlias = quoteIdentifier(alias);
|
|
3404
|
+
selectParts.push(
|
|
3405
|
+
`CASE WHEN ${quotedAlias}.${quotedRecordIdColumn} IS NOT NULL THEN 'true' ELSE 'false' END AS ${quotedAlias}`
|
|
3406
|
+
);
|
|
3407
|
+
aliasMetadata.push({
|
|
3408
|
+
alias,
|
|
3409
|
+
membershipKey,
|
|
3410
|
+
field: membership.field,
|
|
3411
|
+
contains: membership.contains,
|
|
3412
|
+
originalAlias
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
for (const operation of options.operations) {
|
|
3416
|
+
switch (operation.type) {
|
|
3417
|
+
case "count":
|
|
3418
|
+
selectParts.push("COUNT(*) AS count");
|
|
3419
|
+
break;
|
|
3420
|
+
case "sum": {
|
|
3421
|
+
const fieldName = operation.field;
|
|
3422
|
+
selectParts.push(
|
|
3423
|
+
`SUM(${quotedMainTable}.${quoteIdentifier(
|
|
3424
|
+
fieldName
|
|
3425
|
+
)}) AS ${quoteIdentifier(`sum_${fieldName}`)}`
|
|
3426
|
+
);
|
|
3427
|
+
break;
|
|
3428
|
+
}
|
|
3429
|
+
case "avg": {
|
|
3430
|
+
const fieldName = operation.field;
|
|
3431
|
+
selectParts.push(
|
|
3432
|
+
`AVG(${quotedMainTable}.${quoteIdentifier(
|
|
3433
|
+
fieldName
|
|
3434
|
+
)}) AS ${quoteIdentifier(`avg_${fieldName}`)}`
|
|
3435
|
+
);
|
|
3436
|
+
break;
|
|
3437
|
+
}
|
|
3438
|
+
case "min": {
|
|
3439
|
+
const fieldName = operation.field;
|
|
3440
|
+
selectParts.push(
|
|
3441
|
+
`MIN(${quotedMainTable}.${quoteIdentifier(
|
|
3442
|
+
fieldName
|
|
3443
|
+
)}) AS ${quoteIdentifier(`min_${fieldName}`)}`
|
|
3444
|
+
);
|
|
3445
|
+
break;
|
|
3446
|
+
}
|
|
3447
|
+
case "max": {
|
|
3448
|
+
const fieldName = operation.field;
|
|
3449
|
+
selectParts.push(
|
|
3450
|
+
`MAX(${quotedMainTable}.${quoteIdentifier(
|
|
3451
|
+
fieldName
|
|
3452
|
+
)}) AS ${quoteIdentifier(`max_${fieldName}`)}`
|
|
3453
|
+
);
|
|
3454
|
+
break;
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
let sql = `SELECT ${selectParts.join(", ")} FROM ${quotedMainTable}`;
|
|
3459
|
+
aliasMetadata.forEach((detail, index) => {
|
|
3460
|
+
const membership = stringSetMemberships[index];
|
|
3461
|
+
const junctionTable = this.getDatabaseJunctionTableName(
|
|
3462
|
+
modelName,
|
|
3463
|
+
membership.field
|
|
3464
|
+
);
|
|
3465
|
+
const quotedJunctionTable = quoteIdentifier(junctionTable);
|
|
3466
|
+
const quotedAlias = quoteIdentifier(detail.alias);
|
|
3467
|
+
sql += ` LEFT JOIN ${quotedJunctionTable} AS ${quotedAlias} ON ${quotedAlias}.${quotedRecordIdColumn} = ${quotedMainTable}.${quotedIdColumn} AND ${quotedAlias}.${quotedValueColumn} = ?`;
|
|
3468
|
+
});
|
|
3469
|
+
let params = [];
|
|
3470
|
+
for (const membership of stringSetMemberships) {
|
|
3471
|
+
params.push(membership.contains);
|
|
3472
|
+
}
|
|
3473
|
+
if (options.filter && Object.keys(options.filter).length > 0) {
|
|
3474
|
+
const whereClause = translator.translateFind(options.filter, {});
|
|
3475
|
+
const whereMatch = whereClause.sql.match(
|
|
3476
|
+
/WHERE\s+(.+?)(?:\s+ORDER\s+BY|\s+LIMIT|\s*$)/i
|
|
3477
|
+
);
|
|
3478
|
+
if (whereMatch && whereMatch[1]) {
|
|
3479
|
+
sql += ` WHERE ${whereMatch[1]}`;
|
|
3480
|
+
params = params.concat(whereClause.params);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
const groupByParts = [];
|
|
3484
|
+
for (const field of regularGroupBy) {
|
|
3485
|
+
groupByParts.push(`${quotedMainTable}.${quoteIdentifier(field)}`);
|
|
3486
|
+
}
|
|
3487
|
+
aliasMetadata.forEach((detail) => {
|
|
3488
|
+
const quotedAlias = quoteIdentifier(detail.alias);
|
|
3489
|
+
groupByParts.push(`${quotedAlias}.${quotedRecordIdColumn}`);
|
|
3490
|
+
});
|
|
3491
|
+
if (groupByParts.length > 0) {
|
|
3492
|
+
sql += ` GROUP BY ${groupByParts.join(", ")}`;
|
|
3493
|
+
}
|
|
3494
|
+
if (options.sort) {
|
|
3495
|
+
const requestedField = options.sort.field;
|
|
3496
|
+
let sortExpression;
|
|
3497
|
+
if (requestedField === "count") {
|
|
3498
|
+
sortExpression = "COUNT(*)";
|
|
3499
|
+
} else {
|
|
3500
|
+
const membershipDetail = aliasMetadata.find(
|
|
3501
|
+
(detail) => detail.alias === requestedField || detail.originalAlias === requestedField || detail.membershipKey === requestedField
|
|
3502
|
+
);
|
|
3503
|
+
if (membershipDetail) {
|
|
3504
|
+
sortExpression = quoteIdentifier(membershipDetail.alias);
|
|
3505
|
+
} else if (requestedField.startsWith("sum_") || requestedField.startsWith("avg_") || requestedField.startsWith("min_") || requestedField.startsWith("max_")) {
|
|
3506
|
+
sortExpression = quoteIdentifier(requestedField);
|
|
3507
|
+
} else {
|
|
3508
|
+
sortExpression = `${quotedMainTable}.${quoteIdentifier(
|
|
3509
|
+
requestedField
|
|
3510
|
+
)}`;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
const sqlDirection = options.sort.direction === 1 ? "ASC" : "DESC";
|
|
3514
|
+
sql += ` ORDER BY ${sortExpression} ${sqlDirection}`;
|
|
3515
|
+
}
|
|
3516
|
+
if (options.limit) {
|
|
3517
|
+
sql += ` LIMIT ${options.limit}`;
|
|
3518
|
+
}
|
|
3519
|
+
return { sql, params, aliasMetadata };
|
|
3520
|
+
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Process aggregation results into nested structure
|
|
3523
|
+
*/
|
|
3524
|
+
static processAggregationResults(results, options, aliasMetadata) {
|
|
3525
|
+
if (results.length === 0) {
|
|
3526
|
+
return {};
|
|
3527
|
+
}
|
|
3528
|
+
const membershipAliasLookup = /* @__PURE__ */ new Map();
|
|
3529
|
+
if (aliasMetadata) {
|
|
3530
|
+
for (const detail of aliasMetadata) {
|
|
3531
|
+
membershipAliasLookup.set(detail.membershipKey, detail.alias);
|
|
3532
|
+
membershipAliasLookup.set(detail.originalAlias, detail.alias);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
if (options.groupBy.length === 1 && typeof options.groupBy[0] === "string" && results[0].hasOwnProperty("group_key")) {
|
|
3536
|
+
const facetResult = {};
|
|
3537
|
+
for (const row of results) {
|
|
3538
|
+
const key = row.group_key;
|
|
3539
|
+
facetResult[key] = {};
|
|
3540
|
+
for (const operation of options.operations) {
|
|
3541
|
+
if (operation.type === "count") {
|
|
3542
|
+
facetResult[key].count = row.count;
|
|
3543
|
+
} else {
|
|
3544
|
+
const operationKey = `${operation.type}_${operation.field}`;
|
|
3545
|
+
facetResult[key][operationKey] = row[operationKey];
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
if (options.operations.length === 1 && options.operations[0].type === "count") {
|
|
3549
|
+
facetResult[key] = row.count;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
return facetResult;
|
|
3553
|
+
}
|
|
3554
|
+
const nestedResult = {};
|
|
3555
|
+
for (const row of results) {
|
|
3556
|
+
let current = nestedResult;
|
|
3557
|
+
for (let i = 0; i < options.groupBy.length; i++) {
|
|
3558
|
+
const groupBy = options.groupBy[i];
|
|
3559
|
+
let key;
|
|
3560
|
+
if (typeof groupBy === "string") {
|
|
3561
|
+
key = String(row[groupBy]);
|
|
3562
|
+
} else {
|
|
3563
|
+
const membershipKey = this.buildMembershipKey(
|
|
3564
|
+
groupBy.field,
|
|
3565
|
+
groupBy.contains
|
|
3566
|
+
);
|
|
3567
|
+
const resolvedAlias = membershipAliasLookup.get(membershipKey) || membershipAliasLookup.get(
|
|
3568
|
+
`has_${groupBy.field}_${groupBy.contains}`
|
|
3569
|
+
);
|
|
3570
|
+
if (!resolvedAlias) {
|
|
3571
|
+
throw new Error(
|
|
3572
|
+
`Missing alias metadata for StringSet membership ${groupBy.field}:${groupBy.contains}`
|
|
3573
|
+
);
|
|
3574
|
+
}
|
|
3575
|
+
key = row[resolvedAlias];
|
|
3576
|
+
}
|
|
3577
|
+
if (i === options.groupBy.length - 1) {
|
|
3578
|
+
current[key] = {};
|
|
3579
|
+
for (const operation of options.operations) {
|
|
3580
|
+
if (operation.type === "count") {
|
|
3581
|
+
current[key].count = row.count;
|
|
3582
|
+
} else {
|
|
3583
|
+
const operationKey = `${operation.type}_${operation.field}`;
|
|
3584
|
+
current[key][operationKey] = row[operationKey];
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
if (options.operations.length === 1 && options.operations[0].type === "count") {
|
|
3588
|
+
current[key] = row.count;
|
|
3589
|
+
}
|
|
3590
|
+
} else {
|
|
3591
|
+
if (!current[key]) {
|
|
3592
|
+
current[key] = {};
|
|
3593
|
+
}
|
|
3594
|
+
current = current[key];
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
return nestedResult;
|
|
3599
|
+
}
|
|
3600
|
+
/**
|
|
3601
|
+
* Document-style query API - returns single result or null
|
|
3602
|
+
*/
|
|
3603
|
+
static async queryOne(filter = {}, options) {
|
|
3604
|
+
const result = await this.query(filter, { ...options, limit: 1 });
|
|
3605
|
+
return result.data.length > 0 ? result.data[0] : null;
|
|
3606
|
+
}
|
|
3607
|
+
/**
|
|
3608
|
+
* Document-style count API
|
|
3609
|
+
*/
|
|
3610
|
+
static async count(filter = {}, options) {
|
|
3611
|
+
if (!_BaseModel.dbInstance) {
|
|
3612
|
+
const modelNameForError = this.modelName || this.name || "BaseModel";
|
|
3613
|
+
throw new Error(
|
|
3614
|
+
`[${modelNameForError}] Database not initialized for count. Connect at least one document via initJsBao(...).connectDocument or call initializeForDocument(yDoc, db, docId, permissionHint) before running counts.`
|
|
3615
|
+
);
|
|
3616
|
+
}
|
|
3617
|
+
const schema = this.getSchema?.();
|
|
3618
|
+
if (!schema || !schema.options?.name) {
|
|
3619
|
+
throw new Error("Model not properly initialized for count");
|
|
3620
|
+
}
|
|
3621
|
+
const modelName = schema.options.name;
|
|
3622
|
+
Logger.verbose(`[${modelName}] Executing count:`, { filter, options });
|
|
3623
|
+
const translator = new DocumentQueryTranslator(modelName, schema.fields);
|
|
3624
|
+
const translatedQuery = translator.translateCount(filter, options);
|
|
3625
|
+
Logger.verbose(`[${modelName}] Translated count SQL:`, translatedQuery.sql);
|
|
3626
|
+
Logger.verbose(`[${modelName}] Count SQL params:`, translatedQuery.params);
|
|
3627
|
+
const results = await _BaseModel.dbInstance.query(
|
|
3628
|
+
translatedQuery.sql,
|
|
3629
|
+
translatedQuery.params
|
|
3630
|
+
);
|
|
3631
|
+
const count = results[0]?.count || 0;
|
|
3632
|
+
Logger.verbose(`[${modelName}] Count returned:`, count);
|
|
3633
|
+
return count;
|
|
3634
|
+
}
|
|
3635
|
+
static async findAll() {
|
|
3636
|
+
const schema = this.getSchema?.();
|
|
3637
|
+
if (!schema || !schema.options.name)
|
|
3638
|
+
throw new Error("Model not properly initialized for findAll");
|
|
3639
|
+
const modelName = schema.options.name;
|
|
3640
|
+
const verboseEnabled = Logger.getLogLevel() >= 5 /* VERBOSE */;
|
|
3641
|
+
if (verboseEnabled) {
|
|
3642
|
+
Logger.verbose(`[${modelName}] Finding all items`);
|
|
3643
|
+
}
|
|
3644
|
+
const modelConstructor = this;
|
|
3645
|
+
const allInstances = [];
|
|
3646
|
+
if (modelConstructor.documentYMaps && modelConstructor.connectedDocuments && modelConstructor.connectedDocuments.size > 0) {
|
|
3647
|
+
if (verboseEnabled) {
|
|
3648
|
+
Logger.verbose(`[${modelName}.findAll] Using multi-document system`);
|
|
3649
|
+
}
|
|
3650
|
+
for (const [docId] of modelConstructor.connectedDocuments) {
|
|
3651
|
+
if (verboseEnabled) {
|
|
3652
|
+
Logger.verbose(
|
|
3653
|
+
`[${modelName}.findAll] Searching in document: ${docId}`
|
|
3654
|
+
);
|
|
3655
|
+
}
|
|
3656
|
+
const documentYMapKey = `${docId}_${modelName}`;
|
|
3657
|
+
const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
|
|
3658
|
+
if (documentYMap) {
|
|
3659
|
+
if (verboseEnabled) {
|
|
3660
|
+
Logger.verbose(
|
|
3661
|
+
`[${modelName}.findAll] DocumentYMap found for ${docId}, keys: [${Array.from(
|
|
3662
|
+
documentYMap.keys()
|
|
3663
|
+
).join(", ")}]`
|
|
3664
|
+
);
|
|
3665
|
+
}
|
|
3666
|
+
for (const [recordId, recordYMap] of documentYMap.entries()) {
|
|
3667
|
+
if (recordYMap instanceof Y.Map) {
|
|
3668
|
+
const instance = new this({ id: recordId });
|
|
3669
|
+
instance._metaDocId = docId;
|
|
3670
|
+
const connectedDoc = modelConstructor.connectedDocuments.get(docId);
|
|
3671
|
+
if (connectedDoc) {
|
|
3672
|
+
instance._metaPermissionHint = connectedDoc.permissionHint;
|
|
3673
|
+
}
|
|
3674
|
+
allInstances.push(instance);
|
|
3675
|
+
} else {
|
|
3676
|
+
Logger.warn(
|
|
3677
|
+
`[${modelName}] Found legacy plain object in document ${docId}, consider migrating data`
|
|
3678
|
+
);
|
|
3679
|
+
allInstances.push(new this(recordYMap));
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
if (verboseEnabled) {
|
|
3686
|
+
Logger.verbose(
|
|
3687
|
+
`[${modelName}.findAll] Found ${allInstances.length} total instances`
|
|
3688
|
+
);
|
|
3689
|
+
}
|
|
3690
|
+
return allInstances;
|
|
3691
|
+
}
|
|
3692
|
+
static async findByUnique(constraintName, value) {
|
|
3693
|
+
const modelConstructor = this;
|
|
3694
|
+
const schema = modelConstructor.getSchema();
|
|
3695
|
+
const currentModelName = modelConstructor.modelName || schema?.options?.name || "BaseModel";
|
|
3696
|
+
if (!schema || !schema.options || !schema.resolvedUniqueConstraints) {
|
|
3697
|
+
throw new Error(
|
|
3698
|
+
`[${currentModelName}] Schema or unique constraints not loaded for findByUnique.`
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
const constraint = schema.resolvedUniqueConstraints.find(
|
|
3702
|
+
(c) => c.name === constraintName
|
|
3703
|
+
);
|
|
3704
|
+
if (!constraint) {
|
|
3705
|
+
throw new Error(
|
|
3706
|
+
`[${currentModelName}] Unique constraint named '${constraintName}' not found.`
|
|
3707
|
+
);
|
|
3708
|
+
}
|
|
3709
|
+
const keyValues = Array.isArray(value) ? value : [value];
|
|
3710
|
+
const uniqueKeyString = modelConstructor._buildKeyFromValues(
|
|
3711
|
+
constraint.fields,
|
|
3712
|
+
keyValues,
|
|
3713
|
+
currentModelName,
|
|
3714
|
+
constraint.name
|
|
3715
|
+
);
|
|
3716
|
+
if (uniqueKeyString === null) {
|
|
3717
|
+
Logger.verbose(
|
|
3718
|
+
`[${currentModelName}] findByUnique for '${constraintName}': unique key could not be built. Returning null.`
|
|
3719
|
+
);
|
|
3720
|
+
return null;
|
|
3721
|
+
}
|
|
3722
|
+
if (modelConstructor.documentYMaps && modelConstructor.connectedDocuments && modelConstructor.connectedDocuments.size > 0) {
|
|
3723
|
+
Logger.verbose(
|
|
3724
|
+
`[${currentModelName}.findByUnique] Using multi-document system`
|
|
3725
|
+
);
|
|
3726
|
+
for (const [docId] of modelConstructor.connectedDocuments) {
|
|
3727
|
+
const documentInfo = modelConstructor.connectedDocuments.get(docId);
|
|
3728
|
+
if (!documentInfo) continue;
|
|
3729
|
+
const yDoc = documentInfo.yDoc;
|
|
3730
|
+
const constraintMapName2 = `_uniqueIdx_${currentModelName}_${constraint.name}`;
|
|
3731
|
+
const constraintMap2 = yDoc.getMap(constraintMapName2);
|
|
3732
|
+
const recordId2 = constraintMap2.get(uniqueKeyString);
|
|
3733
|
+
if (recordId2) {
|
|
3734
|
+
const documentYMapKey = `${docId}_${currentModelName}`;
|
|
3735
|
+
const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
|
|
3736
|
+
if (documentYMap) {
|
|
3737
|
+
const recordYMap2 = documentYMap.get(recordId2);
|
|
3738
|
+
if (recordYMap2 && recordYMap2 instanceof Y.Map) {
|
|
3739
|
+
const instance = new modelConstructor({
|
|
3740
|
+
id: recordId2
|
|
3741
|
+
});
|
|
3742
|
+
instance._metaDocId = docId;
|
|
3743
|
+
instance._metaPermissionHint = documentInfo.permissionHint;
|
|
3744
|
+
Logger.verbose(
|
|
3745
|
+
`[${currentModelName}.findByUnique] Found record '${recordId2}' in document '${docId}'`
|
|
3746
|
+
);
|
|
3747
|
+
return instance;
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
Logger.verbose(
|
|
3753
|
+
`[${currentModelName}.findByUnique] Record not found in any connected document`
|
|
3754
|
+
);
|
|
3755
|
+
return null;
|
|
3756
|
+
}
|
|
3757
|
+
const legacyDocId = _BaseModel.DEFAULT_LEGACY_DOC_ID;
|
|
3758
|
+
const legacyDocumentMap = modelConstructor.documentYMaps?.get(legacyDocId);
|
|
3759
|
+
const legacyDocInfo = modelConstructor.connectedDocuments?.get(legacyDocId);
|
|
3760
|
+
if (!legacyDocumentMap || !legacyDocInfo) {
|
|
3761
|
+
throw new Error(
|
|
3762
|
+
`[${currentModelName}] No Y.Doc is connected for this model. Connect a document via initJsBao(...).connectDocument or call initializeForDocument(yDoc, db, docId, permissionHint) before using legacy findByUnique.`
|
|
3763
|
+
);
|
|
3764
|
+
}
|
|
3765
|
+
const constraintMapName = `_uniqueIdx_${currentModelName}_${constraint.name}`;
|
|
3766
|
+
const constraintMap = legacyDocInfo.yDoc.getMap(constraintMapName);
|
|
3767
|
+
const recordId = constraintMap.get(uniqueKeyString);
|
|
3768
|
+
if (!recordId) {
|
|
3769
|
+
return null;
|
|
3770
|
+
}
|
|
3771
|
+
const recordYMap = legacyDocumentMap.get(recordId);
|
|
3772
|
+
if (!recordYMap) {
|
|
3773
|
+
return null;
|
|
3774
|
+
}
|
|
3775
|
+
if (recordYMap instanceof Y.Map) {
|
|
3776
|
+
const instance = new modelConstructor({
|
|
3777
|
+
id: recordId
|
|
3778
|
+
});
|
|
3779
|
+
return instance;
|
|
3780
|
+
} else {
|
|
3781
|
+
return new modelConstructor(recordYMap);
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
static async upsertByUnique(constraintName, uniqueLookupValue, dataToUpsert, options) {
|
|
3785
|
+
const modelConstructor = this;
|
|
3786
|
+
const schema = modelConstructor.getSchema();
|
|
3787
|
+
const currentModelName = modelConstructor.modelName || schema?.options?.name || "BaseModel";
|
|
3788
|
+
if (!schema || !schema.options || !schema.resolvedUniqueConstraints) {
|
|
3789
|
+
throw new Error(
|
|
3790
|
+
`[${currentModelName}] Schema or unique constraints not loaded for upsertByUnique.`
|
|
3791
|
+
);
|
|
3792
|
+
}
|
|
3793
|
+
const connectedDocuments = modelConstructor.connectedDocuments;
|
|
3794
|
+
if (!connectedDocuments || connectedDocuments.size === 0) {
|
|
3795
|
+
throw new Error(
|
|
3796
|
+
`[${currentModelName}] No documents are connected. Connect at least one document via initJsBao(...).connectDocument or call initializeForDocument(yDoc, db, docId, permissionHint) before calling upsertByUnique.`
|
|
3797
|
+
);
|
|
3798
|
+
}
|
|
3799
|
+
const constraint = schema.resolvedUniqueConstraints.find(
|
|
3800
|
+
(c) => c.name === constraintName
|
|
3801
|
+
);
|
|
3802
|
+
if (!constraint) {
|
|
3803
|
+
throw new Error(
|
|
3804
|
+
`[${currentModelName}] Unique constraint named '${constraintName}' not found for upsert.`
|
|
3805
|
+
);
|
|
3806
|
+
}
|
|
3807
|
+
const keyValuesForLookup = Array.isArray(uniqueLookupValue) ? uniqueLookupValue : [uniqueLookupValue];
|
|
3808
|
+
constraint.fields.forEach((field, index) => {
|
|
3809
|
+
if (!dataToUpsert.hasOwnProperty(field)) {
|
|
3810
|
+
throw new Error(
|
|
3811
|
+
`[${currentModelName}] upsertByUnique: dataToUpsert is missing required unique field '${field}' from constraint '${constraintName}'.`
|
|
3812
|
+
);
|
|
3813
|
+
}
|
|
3814
|
+
const dataValue = dataToUpsert[field];
|
|
3815
|
+
const lookupValue = keyValuesForLookup[index];
|
|
3816
|
+
if (dataValue !== lookupValue) {
|
|
3817
|
+
throw new Error(
|
|
3818
|
+
`[${currentModelName}] upsertByUnique: Mismatch between dataToUpsert.'${field}' (value: ${dataValue}) and uniqueLookupValue for constraint '${constraintName}' (value: ${lookupValue}).`
|
|
3819
|
+
);
|
|
3820
|
+
}
|
|
3821
|
+
});
|
|
3822
|
+
const uniqueKeyString = modelConstructor._buildKeyFromValues(
|
|
3823
|
+
constraint.fields,
|
|
3824
|
+
keyValuesForLookup,
|
|
3825
|
+
currentModelName,
|
|
3826
|
+
constraint.name
|
|
3827
|
+
);
|
|
3828
|
+
if (uniqueKeyString === null) {
|
|
3829
|
+
throw new Error(
|
|
3830
|
+
`[${currentModelName}] upsertByUnique for '${constraintName}': unique key cannot be built due to null/undefined values. Upsert cannot proceed reliably.`
|
|
3831
|
+
);
|
|
3832
|
+
}
|
|
3833
|
+
const constraintMapName = `_uniqueIdx_${currentModelName}_${constraint.name}`;
|
|
3834
|
+
let existingRecord = null;
|
|
3835
|
+
if (connectedDocuments && connectedDocuments.size > 0) {
|
|
3836
|
+
for (const docId of connectedDocuments.keys()) {
|
|
3837
|
+
const docInfo = connectedDocuments.get(docId);
|
|
3838
|
+
if (docInfo) {
|
|
3839
|
+
const constraintMap = docInfo.yDoc.getMap(constraintMapName);
|
|
3840
|
+
const recordId = constraintMap.get(uniqueKeyString);
|
|
3841
|
+
if (recordId) {
|
|
3842
|
+
existingRecord = await modelConstructor.find(
|
|
3843
|
+
recordId
|
|
3844
|
+
);
|
|
3845
|
+
break;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
} else {
|
|
3850
|
+
}
|
|
3851
|
+
if (options?.objectMustExist) {
|
|
3852
|
+
if (!existingRecord) {
|
|
3853
|
+
throw new RecordNotFoundError(
|
|
3854
|
+
`[${currentModelName}] upsertByUnique (objectMustExist): Record with constraint '${constraintName}' and value(s) starting with '${uniqueKeyString.substring(
|
|
3855
|
+
0,
|
|
3856
|
+
50
|
|
3857
|
+
)}...' not found.`
|
|
3858
|
+
);
|
|
3859
|
+
}
|
|
3860
|
+
Logger.verbose(
|
|
3861
|
+
`[${currentModelName}] upsertByUnique (objectMustExist): Updating existing record ${existingRecord.id}`
|
|
3862
|
+
);
|
|
3863
|
+
Object.assign(existingRecord, dataToUpsert);
|
|
3864
|
+
if (options?.targetDocument) {
|
|
3865
|
+
await existingRecord.save({ targetDocument: options.targetDocument });
|
|
3866
|
+
} else {
|
|
3867
|
+
await existingRecord.save();
|
|
3868
|
+
}
|
|
3869
|
+
return existingRecord;
|
|
3870
|
+
}
|
|
3871
|
+
if (options?.objectMustNotExist) {
|
|
3872
|
+
if (existingRecord) {
|
|
3873
|
+
throw new UniqueConstraintViolationError(
|
|
3874
|
+
`[${currentModelName}] upsertByUnique (objectMustNotExist): Record with constraint '${constraintName}' and value(s) starting with '${uniqueKeyString.substring(
|
|
3875
|
+
0,
|
|
3876
|
+
50
|
|
3877
|
+
)}...' already exists with ID ${existingRecord.id}.`,
|
|
3878
|
+
currentModelName,
|
|
3879
|
+
constraintName,
|
|
3880
|
+
constraint.fields,
|
|
3881
|
+
dataToUpsert.id,
|
|
3882
|
+
existingRecord.id
|
|
3883
|
+
);
|
|
3884
|
+
}
|
|
3885
|
+
Logger.verbose(
|
|
3886
|
+
`[${currentModelName}] upsertByUnique (objectMustNotExist): Creating new record.`
|
|
3887
|
+
);
|
|
3888
|
+
const newInstance = new modelConstructor(
|
|
3889
|
+
dataToUpsert
|
|
3890
|
+
);
|
|
3891
|
+
if (!newInstance.id) {
|
|
3892
|
+
throw new Error(
|
|
3893
|
+
`[${currentModelName}] upsertByUnique: ID must be provided in dataToUpsert or auto-generated by constructor for new records.`
|
|
3894
|
+
);
|
|
3895
|
+
}
|
|
3896
|
+
if (!options?.targetDocument) {
|
|
3897
|
+
throw new Error(
|
|
3898
|
+
`[${currentModelName}] upsertByUnique: targetDocument is required when creating new records.`
|
|
3899
|
+
);
|
|
3900
|
+
}
|
|
3901
|
+
await newInstance.save({ targetDocument: options.targetDocument });
|
|
3902
|
+
return newInstance;
|
|
3903
|
+
}
|
|
3904
|
+
if (existingRecord) {
|
|
3905
|
+
Logger.verbose(
|
|
3906
|
+
`[${currentModelName}] upsertByUnique (default): Updating existing record ${existingRecord.id}`
|
|
3907
|
+
);
|
|
3908
|
+
Object.assign(existingRecord, dataToUpsert);
|
|
3909
|
+
if (options?.targetDocument) {
|
|
3910
|
+
await existingRecord.save({ targetDocument: options.targetDocument });
|
|
3911
|
+
} else {
|
|
3912
|
+
await existingRecord.save();
|
|
3913
|
+
}
|
|
3914
|
+
return existingRecord;
|
|
3915
|
+
} else {
|
|
3916
|
+
Logger.verbose(
|
|
3917
|
+
`[${currentModelName}] upsertByUnique (default): Creating new record.`
|
|
3918
|
+
);
|
|
3919
|
+
const newInstance = new modelConstructor(
|
|
3920
|
+
dataToUpsert
|
|
3921
|
+
);
|
|
3922
|
+
if (!newInstance.id) {
|
|
3923
|
+
throw new Error(
|
|
3924
|
+
`[${currentModelName}] upsertByUnique: ID must be provided in dataToUpsert or auto-generated by constructor for new records.`
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3927
|
+
if (!options?.targetDocument) {
|
|
3928
|
+
throw new Error(
|
|
3929
|
+
`[${currentModelName}] upsertByUnique: targetDocument is required when creating new records.`
|
|
3930
|
+
);
|
|
3931
|
+
}
|
|
3932
|
+
await newInstance.save({ targetDocument: options.targetDocument });
|
|
3933
|
+
return newInstance;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
/**
|
|
3937
|
+
* Execute a callback with automatic transaction handling for all modified models
|
|
3938
|
+
*/
|
|
3939
|
+
static async withTransaction(callback) {
|
|
3940
|
+
const modifiedModels = /* @__PURE__ */ new Set();
|
|
3941
|
+
const originalSetValue = _BaseModel.prototype.setValue;
|
|
3942
|
+
_BaseModel.prototype.setValue = function(fieldKey, value) {
|
|
3943
|
+
modifiedModels.add(this);
|
|
3944
|
+
return originalSetValue.call(this, fieldKey, value);
|
|
3945
|
+
};
|
|
3946
|
+
try {
|
|
3947
|
+
const result = await callback();
|
|
3948
|
+
if (modifiedModels.size > 0) {
|
|
3949
|
+
Logger.verbose(
|
|
3950
|
+
`[BaseModel] Transaction saving ${modifiedModels.size} modified models`
|
|
3951
|
+
);
|
|
3952
|
+
let yDoc = null;
|
|
3953
|
+
const firstModel = Array.from(modifiedModels)[0];
|
|
3954
|
+
const modelConstructor = firstModel.constructor;
|
|
3955
|
+
if (modelConstructor.connectedDocuments && modelConstructor.connectedDocuments.size > 0) {
|
|
3956
|
+
yDoc = Array.from(modelConstructor.connectedDocuments.values())[0].yDoc;
|
|
3957
|
+
}
|
|
3958
|
+
if (yDoc) {
|
|
3959
|
+
await yDoc.transact(async () => {
|
|
3960
|
+
for (const model of modifiedModels) {
|
|
3961
|
+
if (model.isDirty) {
|
|
3962
|
+
await model.save();
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
}, "batch-model-transaction");
|
|
3966
|
+
} else {
|
|
3967
|
+
for (const model of modifiedModels) {
|
|
3968
|
+
if (model.isDirty) {
|
|
3969
|
+
await model.save();
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
return result;
|
|
3975
|
+
} finally {
|
|
3976
|
+
_BaseModel.prototype.setValue = originalSetValue;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
/**
|
|
3980
|
+
* Sets up deep observation on a nested YMap to sync field-level changes to the database
|
|
3981
|
+
*/
|
|
3982
|
+
static setupNestedYMapObserver(recordId, recordYMap) {
|
|
3983
|
+
const modelConstructor = this;
|
|
3984
|
+
const schema = modelConstructor.getSchema();
|
|
3985
|
+
const modelName = schema?.options?.name;
|
|
3986
|
+
if (!modelName) {
|
|
3987
|
+
Logger.error(
|
|
3988
|
+
`[${modelConstructor.name}] Cannot setup nested YMap observer: model name not found`
|
|
3989
|
+
);
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
Logger.verbose(
|
|
3993
|
+
`[${modelName}] Setting up nested YMap observer for record ${recordId}`
|
|
3994
|
+
);
|
|
3995
|
+
recordYMap.observe(async (event) => {
|
|
3996
|
+
Logger.verbose(
|
|
3997
|
+
`[${modelName}] Nested YMap change detected for record ${recordId}:`,
|
|
3998
|
+
event
|
|
3999
|
+
);
|
|
4000
|
+
const currentDbInstance = _BaseModel.dbInstance;
|
|
4001
|
+
if (!currentDbInstance) {
|
|
4002
|
+
Logger.error(
|
|
4003
|
+
`[${modelName}] DB instance not available for nested YMap observer on record ${recordId}`
|
|
4004
|
+
);
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
const updatedData = {};
|
|
4008
|
+
const unknownFields = [];
|
|
4009
|
+
for (const [key, value] of recordYMap.entries()) {
|
|
4010
|
+
const fieldOptions = schema?.fields?.get(key);
|
|
4011
|
+
if (!fieldOptions) {
|
|
4012
|
+
unknownFields.push(key);
|
|
4013
|
+
continue;
|
|
4014
|
+
}
|
|
4015
|
+
if (fieldOptions.type === "stringset") {
|
|
4016
|
+
continue;
|
|
4017
|
+
}
|
|
4018
|
+
if (value !== void 0) {
|
|
4019
|
+
updatedData[key] = value;
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
if (unknownFields.length > 0) {
|
|
4023
|
+
Logger.warn(
|
|
4024
|
+
`[${modelName}] Ignoring unknown fields [${unknownFields.join(
|
|
4025
|
+
", "
|
|
4026
|
+
)}] when syncing nested record ${recordId}`
|
|
4027
|
+
);
|
|
4028
|
+
}
|
|
4029
|
+
if (!updatedData.id) {
|
|
4030
|
+
Logger.warn(
|
|
4031
|
+
`[${modelName}] Nested YMap change for ${recordId} missing id field, skipping DB sync`
|
|
4032
|
+
);
|
|
4033
|
+
return;
|
|
4034
|
+
}
|
|
4035
|
+
try {
|
|
4036
|
+
Logger.verbose(
|
|
4037
|
+
`[${modelName}] Syncing nested YMap changes to database for record ${recordId}:`,
|
|
4038
|
+
updatedData
|
|
4039
|
+
);
|
|
4040
|
+
await currentDbInstance.insert(modelName, {
|
|
4041
|
+
...updatedData,
|
|
4042
|
+
type: modelName
|
|
4043
|
+
});
|
|
4044
|
+
Logger.verbose(
|
|
4045
|
+
`[${modelName}] Database sync completed for nested YMap changes on record ${recordId}`
|
|
4046
|
+
);
|
|
4047
|
+
} catch (error) {
|
|
4048
|
+
Logger.error(
|
|
4049
|
+
`[${modelName}] Error syncing nested YMap changes to database for record ${recordId}:`,
|
|
4050
|
+
error,
|
|
4051
|
+
updatedData
|
|
4052
|
+
);
|
|
4053
|
+
}
|
|
4054
|
+
modelConstructor.notifyListeners();
|
|
4055
|
+
});
|
|
4056
|
+
}
|
|
4057
|
+
/**
|
|
4058
|
+
* Sets up deep observation on a nested YMap for a specific document to sync field-level changes to the database
|
|
4059
|
+
*/
|
|
4060
|
+
static setupNestedYMapObserverForDocument(recordId, recordYMap, docId, permissionHint) {
|
|
4061
|
+
const modelConstructor = this;
|
|
4062
|
+
const schema = modelConstructor.getSchema();
|
|
4063
|
+
const modelName = schema?.options?.name;
|
|
4064
|
+
if (!modelName) {
|
|
4065
|
+
Logger.error(
|
|
4066
|
+
`[${modelConstructor.name}] Cannot setup nested YMap observer: model name not found`
|
|
4067
|
+
);
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
Logger.verbose(
|
|
4071
|
+
`[${modelName}] Setting up nested YMap observer for record ${recordId} in document ${docId}`
|
|
4072
|
+
);
|
|
4073
|
+
recordYMap.observe(async (event) => {
|
|
4074
|
+
Logger.verbose(
|
|
4075
|
+
`[${modelName}] Nested YMap change detected for record ${recordId} in document ${docId}:`,
|
|
4076
|
+
event
|
|
4077
|
+
);
|
|
4078
|
+
const currentDbInstance = _BaseModel.dbInstance;
|
|
4079
|
+
if (!currentDbInstance) {
|
|
4080
|
+
Logger.error(
|
|
4081
|
+
`[${modelName}] DB instance not available for nested YMap observer on record ${recordId} in document ${docId}`
|
|
4082
|
+
);
|
|
4083
|
+
return;
|
|
4084
|
+
}
|
|
4085
|
+
const updatedData = {};
|
|
4086
|
+
const unknownFields = [];
|
|
4087
|
+
for (const [key, value] of recordYMap.entries()) {
|
|
4088
|
+
const fieldOptions = schema?.fields?.get(key);
|
|
4089
|
+
if (!fieldOptions) {
|
|
4090
|
+
unknownFields.push(key);
|
|
4091
|
+
continue;
|
|
4092
|
+
}
|
|
4093
|
+
if (fieldOptions.type === "stringset") {
|
|
4094
|
+
continue;
|
|
4095
|
+
}
|
|
4096
|
+
if (value !== void 0) {
|
|
4097
|
+
updatedData[key] = value;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
if (unknownFields.length > 0) {
|
|
4101
|
+
Logger.warn(
|
|
4102
|
+
`[${modelName}] Ignoring unknown fields [${unknownFields.join(
|
|
4103
|
+
", "
|
|
4104
|
+
)}] when syncing nested record ${recordId} in document ${docId}`
|
|
4105
|
+
);
|
|
4106
|
+
}
|
|
4107
|
+
if (!updatedData.id) {
|
|
4108
|
+
Logger.warn(
|
|
4109
|
+
`[${modelName}] Nested YMap change for ${recordId} in document ${docId} missing id field, skipping DB sync`
|
|
4110
|
+
);
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
4113
|
+
try {
|
|
4114
|
+
Logger.verbose(
|
|
4115
|
+
`[${modelName}] Syncing nested YMap changes to database for record ${recordId} in document ${docId}:`,
|
|
4116
|
+
updatedData
|
|
4117
|
+
);
|
|
4118
|
+
await currentDbInstance.insert(modelName, {
|
|
4119
|
+
...updatedData,
|
|
4120
|
+
type: modelName,
|
|
4121
|
+
_meta_doc_id: docId,
|
|
4122
|
+
_meta_permission_hint: permissionHint
|
|
4123
|
+
});
|
|
4124
|
+
Logger.verbose(
|
|
4125
|
+
`[${modelName}] Database sync completed for nested YMap changes on record ${recordId} in document ${docId}`
|
|
4126
|
+
);
|
|
4127
|
+
} catch (error) {
|
|
4128
|
+
Logger.error(
|
|
4129
|
+
`[${modelName}] Error syncing nested YMap changes to database for record ${recordId} in document ${docId}:`,
|
|
4130
|
+
error,
|
|
4131
|
+
updatedData
|
|
4132
|
+
);
|
|
4133
|
+
}
|
|
4134
|
+
modelConstructor.notifyListeners();
|
|
4135
|
+
});
|
|
4136
|
+
}
|
|
4137
|
+
};
|
|
4138
|
+
|
|
4139
|
+
export {
|
|
4140
|
+
StringSet,
|
|
4141
|
+
assertValidIdentifier,
|
|
4142
|
+
quoteIdentifier,
|
|
4143
|
+
DocumentQueryTranslator,
|
|
4144
|
+
LogLevel,
|
|
4145
|
+
Logger,
|
|
4146
|
+
UniqueConstraintViolationError,
|
|
4147
|
+
RecordNotFoundError,
|
|
4148
|
+
generateULID,
|
|
4149
|
+
BaseModel
|
|
4150
|
+
};
|
|
4151
|
+
//# sourceMappingURL=chunk-6UX3YSCW.js.map
|