jsonbadger 0.5.0 → 0.6.1
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 +36 -18
- package/docs/api/connection.md +144 -0
- package/docs/api/delta-tracker.md +106 -0
- package/docs/api/document.md +77 -0
- package/docs/api/field-types.md +329 -0
- package/docs/api/index.md +35 -0
- package/docs/api/model.md +392 -0
- package/docs/api/query-builder.md +81 -0
- package/docs/api/schema.md +204 -0
- package/docs/architecture-flow.md +397 -0
- package/docs/examples.md +495 -218
- package/docs/jsonb-ops.md +171 -0
- package/docs/lifecycle/model-compilation.md +111 -0
- package/docs/lifecycle.md +146 -0
- package/docs/query-translation.md +11 -10
- package/package.json +10 -3
- package/src/connection/connect.js +12 -17
- package/src/connection/connection.js +128 -0
- package/src/connection/server-capabilities.js +60 -59
- package/src/constants/defaults.js +32 -19
- package/src/constants/{id-strategies.js → id-strategy.js} +28 -29
- package/src/constants/intake-mode.js +8 -0
- package/src/debug/debug-logger.js +17 -15
- package/src/errors/model-overwrite-error.js +25 -0
- package/src/errors/query-error.js +25 -23
- package/src/errors/validation-error.js +25 -23
- package/src/field-types/base-field-type.js +137 -140
- package/src/field-types/builtins/advanced.js +365 -365
- package/src/field-types/builtins/index.js +579 -585
- package/src/field-types/field-type-namespace.js +9 -0
- package/src/field-types/registry.js +149 -122
- package/src/index.js +26 -36
- package/src/migration/ensure-index.js +157 -154
- package/src/migration/ensure-schema.js +27 -15
- package/src/migration/ensure-table.js +44 -31
- package/src/migration/schema-indexes-resolver.js +8 -6
- package/src/model/document-instance.js +29 -540
- package/src/model/document.js +60 -0
- package/src/model/factory/constants.js +36 -0
- package/src/model/factory/index.js +58 -0
- package/src/model/model.js +875 -0
- package/src/model/operations/delete-one.js +39 -0
- package/src/model/operations/insert-one.js +35 -0
- package/src/model/operations/query-builder.js +132 -0
- package/src/model/operations/update-one.js +333 -0
- package/src/model/state.js +34 -0
- package/src/schema/field-definition-parser.js +213 -218
- package/src/schema/path-introspection.js +87 -82
- package/src/schema/schema-compiler.js +126 -212
- package/src/schema/schema.js +621 -138
- package/src/sql/index.js +17 -0
- package/src/sql/jsonb/ops.js +153 -0
- package/src/{query → sql/jsonb}/path-parser.js +54 -43
- package/src/sql/jsonb/read/elem-match.js +133 -0
- package/src/{query → sql/jsonb/read}/operators/contains.js +13 -7
- package/src/sql/jsonb/read/operators/elem-match.js +9 -0
- package/src/{query → sql/jsonb/read}/operators/has-all-keys.js +17 -11
- package/src/{query → sql/jsonb/read}/operators/has-any-keys.js +18 -11
- package/src/sql/jsonb/read/operators/has-key.js +12 -0
- package/src/{query → sql/jsonb/read}/operators/jsonpath-exists.js +22 -15
- package/src/{query → sql/jsonb/read}/operators/jsonpath-match.js +22 -15
- package/src/{query → sql/jsonb/read}/operators/size.js +23 -16
- package/src/sql/parameter-binder.js +18 -13
- package/src/sql/read/build-count-query.js +12 -0
- package/src/sql/read/build-find-query.js +25 -0
- package/src/sql/read/limit-skip.js +21 -0
- package/src/sql/read/sort.js +85 -0
- package/src/sql/read/where/base-fields.js +310 -0
- package/src/sql/read/where/casting.js +90 -0
- package/src/sql/read/where/context.js +79 -0
- package/src/sql/read/where/field-clause.js +58 -0
- package/src/sql/read/where/index.js +38 -0
- package/src/sql/read/where/operator-entries.js +29 -0
- package/src/{query → sql/read/where}/operators/all.js +16 -10
- package/src/sql/read/where/operators/eq.js +12 -0
- package/src/{query → sql/read/where}/operators/gt.js +23 -16
- package/src/{query → sql/read/where}/operators/gte.js +23 -16
- package/src/{query → sql/read/where}/operators/in.js +18 -12
- package/src/sql/read/where/operators/index.js +40 -0
- package/src/{query → sql/read/where}/operators/lt.js +23 -16
- package/src/{query → sql/read/where}/operators/lte.js +23 -16
- package/src/sql/read/where/operators/ne.js +12 -0
- package/src/{query → sql/read/where}/operators/nin.js +18 -12
- package/src/{query → sql/read/where}/operators/regex.js +14 -8
- package/src/sql/read/where/operators.js +126 -0
- package/src/sql/read/where/text-operators.js +83 -0
- package/src/sql/run.js +46 -0
- package/src/sql/write/build-delete-query.js +33 -0
- package/src/sql/write/build-insert-query.js +42 -0
- package/src/sql/write/build-update-query.js +65 -0
- package/src/utils/assert.js +34 -27
- package/src/utils/delta-tracker/.archive/1 tracker-redesign-codex-v2.md +250 -0
- package/src/utils/delta-tracker/.archive/1 tracker-redesign-gemini.md +101 -0
- package/src/utils/delta-tracker/.archive/2 evaluation by gemini.txt +65 -0
- package/src/utils/delta-tracker/.archive/2 evaluation by grok.txt +39 -0
- package/src/utils/delta-tracker/.archive/3 gemini evaluate grok.txt +37 -0
- package/src/utils/delta-tracker/.archive/3 grok evaluate gemini.txt +63 -0
- package/src/utils/delta-tracker/.archive/4 gemini veredict.txt +16 -0
- package/src/utils/delta-tracker/.archive/index.1.js +587 -0
- package/src/utils/delta-tracker/.archive/index.2.js +612 -0
- package/src/utils/delta-tracker/index.js +592 -0
- package/src/utils/dirty-tracker/inline.js +335 -0
- package/src/utils/dirty-tracker/instance.js +414 -0
- package/src/utils/dirty-tracker/static.js +343 -0
- package/src/utils/json-safe.js +13 -9
- package/src/utils/object-path.js +227 -33
- package/src/utils/object.js +408 -168
- package/src/utils/string.js +55 -0
- package/src/utils/value.js +169 -30
- package/docs/api.md +0 -152
- package/src/connection/disconnect.js +0 -16
- package/src/connection/pool-store.js +0 -46
- package/src/model/model-factory.js +0 -555
- package/src/query/limit-skip-compiler.js +0 -31
- package/src/query/operators/elem-match.js +0 -3
- package/src/query/operators/eq.js +0 -6
- package/src/query/operators/has-key.js +0 -6
- package/src/query/operators/index.js +0 -60
- package/src/query/operators/ne.js +0 -6
- package/src/query/query-builder.js +0 -93
- package/src/query/sort-compiler.js +0 -30
- package/src/query/where-compiler.js +0 -477
- package/src/sql/sql-runner.js +0 -31
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MODULE RESPONSIBILITY
|
|
3
|
+
* Own document lifecycle, schema validation, timestamp policy, and persistence delegation.
|
|
4
|
+
*/
|
|
5
|
+
import ID_STRATEGY from '#src/constants/id-strategy.js';
|
|
6
|
+
import INTAKE_MODE from '#src/constants/intake-mode.js';
|
|
7
|
+
import QueryError from '#src/errors/query-error.js';
|
|
8
|
+
import {assert_id_strategy_capability} from '#src/connection/server-capabilities.js';
|
|
9
|
+
|
|
10
|
+
import ensure_index_sql from '#src/migration/ensure-index.js';
|
|
11
|
+
import ensure_table_sql from '#src/migration/ensure-table.js';
|
|
12
|
+
import resolve_schema_indexes from '#src/migration/schema-indexes-resolver.js';
|
|
13
|
+
|
|
14
|
+
import Document from '#src/model/document.js';
|
|
15
|
+
import QueryBuilder from '#src/model/operations/query-builder.js';
|
|
16
|
+
import DeltaTracker from '#src/utils/delta-tracker/index.js';
|
|
17
|
+
|
|
18
|
+
import {exec_delete_one} from '#src/model/operations/delete-one.js';
|
|
19
|
+
import {exec_insert_one} from '#src/model/operations/insert-one.js';
|
|
20
|
+
import {exec_update_one} from '#src/model/operations/update-one.js';
|
|
21
|
+
import {base_field_keys, timestamp_fields, unsafe_from_keys} from '#src/model/factory/constants.js';
|
|
22
|
+
|
|
23
|
+
import {is_array} from '#src/utils/array.js';
|
|
24
|
+
import {expand_dot_paths, read_nested_path, split_dot_path, write_nested_path} from '#src/utils/object-path.js';
|
|
25
|
+
import {deep_clone, get_callable, has_own, to_plain_object} from '#src/utils/object.js';
|
|
26
|
+
import {is_function, is_not_object, is_object, is_plain_object} from '#src/utils/value.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Base model constructor that initializes the internal tracked document.
|
|
30
|
+
*
|
|
31
|
+
* @param {*} data
|
|
32
|
+
* @returns {void}
|
|
33
|
+
*/
|
|
34
|
+
function Model(data) {
|
|
35
|
+
const slugs = this.constructor.schema.get_slugs();
|
|
36
|
+
this.document = DeltaTracker(new Document(data), {track: slugs});
|
|
37
|
+
this.is_new = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* DX ALIASES & GETTERS
|
|
42
|
+
*/
|
|
43
|
+
Object.defineProperty(Model.prototype, 'id', {
|
|
44
|
+
get: function () {return this.document.id;},
|
|
45
|
+
set: function (value) {this.document.id = value;}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
Object.defineProperty(Model.prototype, 'created_at', {
|
|
49
|
+
get: function () {return this.document.created_at;},
|
|
50
|
+
set: function (value) {this.document.created_at = value;}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
Object.defineProperty(Model.prototype, 'updated_at', {
|
|
54
|
+
get: function () {return this.document.updated_at;},
|
|
55
|
+
set: function (value) {this.document.updated_at = value;}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
Object.defineProperty(Model.prototype, 'timestamps', {
|
|
59
|
+
get: function () {
|
|
60
|
+
return {
|
|
61
|
+
created_at: this.created_at,
|
|
62
|
+
updated_at: this.updated_at
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
Model.options = null;
|
|
68
|
+
Model.state = null;
|
|
69
|
+
Model.prototype.is_new = null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read one document value by exact path or alias.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} path_name
|
|
75
|
+
* @returns {*}
|
|
76
|
+
*/
|
|
77
|
+
Model.prototype.get = function (path_name) {
|
|
78
|
+
const aliases = this.constructor.schema.aliases;
|
|
79
|
+
const alias_entry = aliases[path_name] || {};
|
|
80
|
+
const resolved_path = alias_entry.path ?? path_name;
|
|
81
|
+
const segments = split_dot_path(resolved_path);
|
|
82
|
+
|
|
83
|
+
if(segments.length === 1) {
|
|
84
|
+
return this.document[segments[0]] ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const path_state = read_nested_path(this.document, segments);
|
|
88
|
+
|
|
89
|
+
if(!path_state.exists) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return path_state.value;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Set one document value by exact path or alias.
|
|
98
|
+
*
|
|
99
|
+
* Schema-aware setter and cast logic still run before assignment.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} path_name
|
|
102
|
+
* @param {*} next_value
|
|
103
|
+
* @returns {Model}
|
|
104
|
+
* @throws {QueryError}
|
|
105
|
+
*/
|
|
106
|
+
Model.prototype.set = function (path_name, next_value) {
|
|
107
|
+
const schema = this.constructor.schema;
|
|
108
|
+
const aliases = schema.aliases;
|
|
109
|
+
const alias_entry = aliases[path_name] || {};
|
|
110
|
+
const resolved_path = alias_entry.path ?? path_name;
|
|
111
|
+
const base_field_root_key = resolved_path.split('.')[0];
|
|
112
|
+
|
|
113
|
+
if(base_field_root_key === 'id') {
|
|
114
|
+
throw new QueryError('Read-only base field cannot be assigned by path mutation', {
|
|
115
|
+
operation: 'set',
|
|
116
|
+
field: base_field_root_key
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const is_timestamp = timestamp_fields.has(base_field_root_key);
|
|
121
|
+
|
|
122
|
+
if(is_timestamp && resolved_path !== base_field_root_key) {
|
|
123
|
+
throw new QueryError('Timestamp fields only support top-level paths', {
|
|
124
|
+
operation: 'set',
|
|
125
|
+
field: base_field_root_key,
|
|
126
|
+
path: resolved_path
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const field_type_path = resolve_field_type_path(schema, resolved_path);
|
|
131
|
+
const field_type = schema.get_path(field_type_path) || null;
|
|
132
|
+
let assigned_value = next_value;
|
|
133
|
+
|
|
134
|
+
if(field_type) {
|
|
135
|
+
assigned_value = field_type.apply_set(assigned_value, {
|
|
136
|
+
path: resolved_path,
|
|
137
|
+
mode: 'set'
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if(is_function(field_type.cast)) {
|
|
141
|
+
assigned_value = field_type.cast(assigned_value, {
|
|
142
|
+
path: resolved_path,
|
|
143
|
+
mode: 'set'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if(is_timestamp) {
|
|
149
|
+
this[base_field_root_key] = assigned_value;
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const path_segments = split_dot_path(resolved_path);
|
|
154
|
+
write_nested_path(this.document, path_segments, assigned_value);
|
|
155
|
+
|
|
156
|
+
return this;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Bind live document-backed fields onto another target object.
|
|
161
|
+
*
|
|
162
|
+
* Default-slug root fields are flattened onto the target root, while
|
|
163
|
+
* registered extra slugs stay nested at their slug root.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} target
|
|
166
|
+
* @returns {object}
|
|
167
|
+
*/
|
|
168
|
+
Model.prototype.bind_document = function (target) {
|
|
169
|
+
const schema = this.constructor.schema;
|
|
170
|
+
const default_slug = schema.get_default_slug();
|
|
171
|
+
const extra_slugs = schema.get_extra_slugs();
|
|
172
|
+
const extra_slug_set = new Set(extra_slugs);
|
|
173
|
+
const seen_root_fields = new Set();
|
|
174
|
+
const proxied_fields = [...base_field_keys, ...extra_slugs];
|
|
175
|
+
|
|
176
|
+
if(is_not_object(target)) {
|
|
177
|
+
throw new Error('bind_document target must be an object');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for(const path_name of Object.keys(schema.$field_types)) {
|
|
181
|
+
const root_field = split_dot_path(path_name)[0];
|
|
182
|
+
|
|
183
|
+
if(base_field_keys.has(root_field) || extra_slug_set.has(root_field) || seen_root_fields.has(root_field)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
seen_root_fields.add(root_field);
|
|
188
|
+
proxied_fields.push(root_field);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for(const field_name of proxied_fields) {
|
|
192
|
+
if(has_own(target, field_name)) {
|
|
193
|
+
throw new Error('bind_document field collision: ' + field_name);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let get_value;
|
|
197
|
+
let set_value;
|
|
198
|
+
|
|
199
|
+
if(base_field_keys.has(field_name)) {
|
|
200
|
+
get_value = () => this[field_name] ?? null;
|
|
201
|
+
set_value = (value) => {
|
|
202
|
+
this[field_name] = value;
|
|
203
|
+
};
|
|
204
|
+
} else if(extra_slug_set.has(field_name)) {
|
|
205
|
+
get_value = () => this.get(field_name);
|
|
206
|
+
set_value = (value) => {
|
|
207
|
+
this.set(field_name, value);
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
const path_name = default_slug + '.' + field_name;
|
|
211
|
+
get_value = () => this.get(path_name);
|
|
212
|
+
set_value = (value) => {
|
|
213
|
+
this.set(path_name, value);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
Object.defineProperty(target, field_name, {
|
|
218
|
+
enumerable: true,
|
|
219
|
+
configurable: true,
|
|
220
|
+
get: get_value,
|
|
221
|
+
set: set_value
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return target;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Conform the current tracked document state to the schema-owned document shape.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} [options]
|
|
232
|
+
* @returns {Model}
|
|
233
|
+
*/
|
|
234
|
+
Model.prototype.$conform_document = function () {
|
|
235
|
+
this.constructor.schema.conform(this.document);
|
|
236
|
+
return this;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Apply schema defaults onto the current tracked document state.
|
|
241
|
+
*
|
|
242
|
+
* @param {object} [options]
|
|
243
|
+
* @returns {Model}
|
|
244
|
+
*/
|
|
245
|
+
Model.prototype.$apply_defaults = function (options = {}) {
|
|
246
|
+
const schema = this.constructor.schema;
|
|
247
|
+
const document = this.document;
|
|
248
|
+
const default_slug = schema.get_default_slug();
|
|
249
|
+
const extra_slug_set = new Set(schema.get_extra_slugs());
|
|
250
|
+
|
|
251
|
+
for(const [path_name, field_type] of Object.entries(schema.$field_types)) {
|
|
252
|
+
const path_segments = split_dot_path(path_name);
|
|
253
|
+
const root_key = path_segments[0];
|
|
254
|
+
let target_root = document;
|
|
255
|
+
let target_segments = path_segments;
|
|
256
|
+
|
|
257
|
+
if(!base_field_keys.has(root_key)) {
|
|
258
|
+
const target_key = extra_slug_set.has(root_key) ? root_key : default_slug;
|
|
259
|
+
const has_target_root = has_own(document, target_key);
|
|
260
|
+
|
|
261
|
+
if(has_target_root && is_not_object(document[target_key])) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if(!has_target_root) {
|
|
266
|
+
document[target_key] = {};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
target_root = document[target_key];
|
|
270
|
+
|
|
271
|
+
if(target_key === root_key) {
|
|
272
|
+
target_segments = path_segments.slice(1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const leaf_key = target_segments[target_segments.length - 1];
|
|
277
|
+
const depth = target_segments.length - 1;
|
|
278
|
+
|
|
279
|
+
let current_object = target_root;
|
|
280
|
+
let write_index = null;
|
|
281
|
+
let path_exists = true;
|
|
282
|
+
let can_write = true;
|
|
283
|
+
|
|
284
|
+
for(const [index, segment] of target_segments.entries()) {
|
|
285
|
+
if(index >= depth) {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if(!has_own(current_object, segment)) {
|
|
290
|
+
path_exists = false;
|
|
291
|
+
write_index = index;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if(is_not_object(current_object[segment])) {
|
|
296
|
+
can_write = false;
|
|
297
|
+
write_index = index;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
current_object = current_object[segment];
|
|
302
|
+
write_index = index + 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if(!can_write) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if(path_exists && has_own(current_object, leaf_key)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const default_value = field_type.resolve_default({
|
|
314
|
+
...options,
|
|
315
|
+
path: path_name,
|
|
316
|
+
document,
|
|
317
|
+
model: this
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if(default_value === undefined) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if(write_index === null) {
|
|
325
|
+
write_index = 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for(const segment of target_segments.slice(write_index, depth)) {
|
|
329
|
+
current_object[segment] = {};
|
|
330
|
+
current_object = current_object[segment];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
current_object[leaf_key] = deep_clone(default_value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return this;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Cast existing schema-backed values on the current tracked document state.
|
|
341
|
+
*
|
|
342
|
+
* @param {object} [options]
|
|
343
|
+
* @returns {Model}
|
|
344
|
+
*/
|
|
345
|
+
Model.prototype.$cast = function (options = {}) {
|
|
346
|
+
const schema = this.constructor.schema;
|
|
347
|
+
const document = this.document;
|
|
348
|
+
const default_slug = schema.get_default_slug();
|
|
349
|
+
const extra_slug_set = new Set(schema.get_extra_slugs());
|
|
350
|
+
|
|
351
|
+
for(const [path_name, field_type] of Object.entries(schema.$field_types)) {
|
|
352
|
+
const path_segments = split_dot_path(path_name);
|
|
353
|
+
const root_key = path_segments[0];
|
|
354
|
+
let target_root = document;
|
|
355
|
+
let target_segments = path_segments;
|
|
356
|
+
|
|
357
|
+
if(!base_field_keys.has(root_key)) {
|
|
358
|
+
const target_key = extra_slug_set.has(root_key) ? root_key : default_slug;
|
|
359
|
+
const has_target_root = has_own(document, target_key);
|
|
360
|
+
const target_value = document[target_key];
|
|
361
|
+
|
|
362
|
+
if(!has_target_root || is_not_object(target_value)) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
target_root = target_value;
|
|
367
|
+
|
|
368
|
+
if(target_key === root_key) {
|
|
369
|
+
target_segments = path_segments.slice(1);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const path_state = read_nested_path(target_root, target_segments);
|
|
374
|
+
|
|
375
|
+
if(!path_state.exists) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const cast_context = {
|
|
380
|
+
...options,
|
|
381
|
+
path: path_name,
|
|
382
|
+
mode: 'cast',
|
|
383
|
+
document,
|
|
384
|
+
model: this
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
let casted_value = path_state.value;
|
|
388
|
+
casted_value = field_type.apply_set(casted_value, cast_context);
|
|
389
|
+
casted_value = field_type.cast(casted_value, cast_context);
|
|
390
|
+
|
|
391
|
+
write_nested_path(target_root, target_segments, casted_value);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return this;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Validate the current tracked document state against schema rules.
|
|
399
|
+
*
|
|
400
|
+
* @param {object} [options]
|
|
401
|
+
* @returns {Model}
|
|
402
|
+
*/
|
|
403
|
+
Model.prototype.$validate = function (options = {}) {
|
|
404
|
+
void options;
|
|
405
|
+
|
|
406
|
+
this.constructor.schema.validate(this.document);
|
|
407
|
+
|
|
408
|
+
return this;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Apply the full schema lifecycle to the current tracked document state.
|
|
413
|
+
*
|
|
414
|
+
* @param {object} [options]
|
|
415
|
+
* @returns {Model}
|
|
416
|
+
*/
|
|
417
|
+
Model.prototype.$normalize = function (options = {}) {
|
|
418
|
+
this.$conform_document(options);
|
|
419
|
+
this.$apply_defaults(options);
|
|
420
|
+
this.$cast(options);
|
|
421
|
+
this.$validate(options);
|
|
422
|
+
|
|
423
|
+
return this;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/*
|
|
427
|
+
* INSTANCE WRITES
|
|
428
|
+
*/
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Validate and persist this document through the compiled model.
|
|
432
|
+
*
|
|
433
|
+
* @returns {Promise<object>}
|
|
434
|
+
* @throws {QueryError}
|
|
435
|
+
*/
|
|
436
|
+
Model.prototype.save = async function () {
|
|
437
|
+
if(this.is_new) {
|
|
438
|
+
return this.insert();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return this.update();
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Insert this model as a new persisted row.
|
|
446
|
+
*
|
|
447
|
+
* @returns {Promise<object>}
|
|
448
|
+
* @throws {QueryError}
|
|
449
|
+
*/
|
|
450
|
+
Model.prototype.insert = async function () {
|
|
451
|
+
const model = this.constructor;
|
|
452
|
+
const document = this.document;
|
|
453
|
+
const data_column = model.options.data_column;
|
|
454
|
+
|
|
455
|
+
model.schema.validate(document);
|
|
456
|
+
apply_timestamps(document);
|
|
457
|
+
|
|
458
|
+
const base_fields = {
|
|
459
|
+
created_at: document.created_at,
|
|
460
|
+
updated_at: document.updated_at
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
if(model.schema.id_strategy === ID_STRATEGY.uuidv7) {
|
|
464
|
+
base_fields.id = document.id;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const data = {
|
|
468
|
+
payload: document[data_column],
|
|
469
|
+
base_fields
|
|
470
|
+
};
|
|
471
|
+
const saved_row = await exec_insert_one(model, data);
|
|
472
|
+
|
|
473
|
+
if(saved_row) {
|
|
474
|
+
this.rebase(new Document(saved_row));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return this;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Update this model through the persisted row path.
|
|
482
|
+
*
|
|
483
|
+
* @returns {Promise<object>}
|
|
484
|
+
* @throws {QueryError}
|
|
485
|
+
*/
|
|
486
|
+
Model.prototype.update = async function () {
|
|
487
|
+
const model = this.constructor;
|
|
488
|
+
const document = this.document;
|
|
489
|
+
|
|
490
|
+
model.schema.validate(document);
|
|
491
|
+
document.updated_at = new Date();
|
|
492
|
+
|
|
493
|
+
if(this.id === undefined || this.id === null) {
|
|
494
|
+
throw new QueryError('Document id is required for save update operations', {
|
|
495
|
+
operation: 'save',
|
|
496
|
+
field: 'id'
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if(document.$has_changes()) {
|
|
501
|
+
// The tracker provides the delta shape, and row-level timestamps stay at the root.
|
|
502
|
+
const update_payload = document.$get_delta();
|
|
503
|
+
update_payload.updated_at = document.updated_at;
|
|
504
|
+
|
|
505
|
+
const filter = {id: this.id};
|
|
506
|
+
const updated_row = await exec_update_one(model, filter, update_payload);
|
|
507
|
+
|
|
508
|
+
if(updated_row) {
|
|
509
|
+
this.rebase(new Document(updated_row));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return this;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
Model.prototype.rebase = function (reference) {
|
|
517
|
+
let document;
|
|
518
|
+
|
|
519
|
+
if(reference instanceof Model) {
|
|
520
|
+
document = reference.document;
|
|
521
|
+
} else if(reference instanceof Document) {
|
|
522
|
+
document = reference;
|
|
523
|
+
} else {
|
|
524
|
+
throw new Error('rebase reference must be a Model or Document');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const data = deep_clone(document);
|
|
528
|
+
|
|
529
|
+
this.is_new = false;
|
|
530
|
+
this.document.init(data);
|
|
531
|
+
this.document.$rebase_changes();
|
|
532
|
+
|
|
533
|
+
return this;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/*
|
|
537
|
+
* CONSTRUCTION
|
|
538
|
+
*/
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Build a new document from external payload input.
|
|
542
|
+
*
|
|
543
|
+
* @param {*} data
|
|
544
|
+
* @param {object} [options]
|
|
545
|
+
* @returns {object}
|
|
546
|
+
*/
|
|
547
|
+
Model.from = function (data, options = {}) {
|
|
548
|
+
const model = this;
|
|
549
|
+
const fields = extract_from_document_fields(model, data);
|
|
550
|
+
const instance = new model(fields);
|
|
551
|
+
|
|
552
|
+
// Build a new document, then run the composed lifecycle on the tracked instance state.
|
|
553
|
+
instance.$normalize({mode: INTAKE_MODE.from, ...options});
|
|
554
|
+
instance.document.$rebase_changes();
|
|
555
|
+
|
|
556
|
+
return instance;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Build a persisted document from row-like input.
|
|
561
|
+
*
|
|
562
|
+
* @param {*} data
|
|
563
|
+
* @param {object} [options]
|
|
564
|
+
* @returns {object}
|
|
565
|
+
*/
|
|
566
|
+
Model.hydrate = function (data, options = {}) {
|
|
567
|
+
const model = this;
|
|
568
|
+
const fields = extract_hydrated_document_fields(model, data);
|
|
569
|
+
const instance = new model(fields);
|
|
570
|
+
|
|
571
|
+
// Build a persisted document, then run the composed lifecycle on the tracked instance state.
|
|
572
|
+
instance.$normalize({mode: INTAKE_MODE.hydrate, ...options});
|
|
573
|
+
instance.document.$rebase_changes();
|
|
574
|
+
instance.is_new = false;
|
|
575
|
+
|
|
576
|
+
return instance;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
Model.cast = function (document) {
|
|
580
|
+
return this.schema.cast(document);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/*
|
|
584
|
+
* RUNTIME BOOKKEEPING
|
|
585
|
+
*/
|
|
586
|
+
Model.reset_index_cache = function () {
|
|
587
|
+
this.state.indexes_ensured = false;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/*
|
|
591
|
+
* PERSISTENCE WRITES
|
|
592
|
+
*/
|
|
593
|
+
|
|
594
|
+
Model.create = async function (documents) {
|
|
595
|
+
const model = this;
|
|
596
|
+
|
|
597
|
+
if(is_array(documents)) {
|
|
598
|
+
// Replace this fan-out with insert_many once a bulk insert path exists.
|
|
599
|
+
return await Promise.all(documents.map((document) => {
|
|
600
|
+
return model.insert_one(document);
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return model.insert_one(documents);
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Insert one plain payload input through the compiled model convenience API.
|
|
609
|
+
*
|
|
610
|
+
* @param {*|object} record
|
|
611
|
+
* @returns {Promise<object>}
|
|
612
|
+
* @throws {QueryError}
|
|
613
|
+
*/
|
|
614
|
+
Model.insert_one = async function (record) {
|
|
615
|
+
const model = this;
|
|
616
|
+
if(!is_plain_object(record)) {
|
|
617
|
+
throw new Error('Model.insert_one accepts only plain object; use doc.insert() or doc.save() for existing documents');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const instance = model.from(record);
|
|
621
|
+
return instance.insert();
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Update one persisted document through the compiled model convenience API.
|
|
626
|
+
*
|
|
627
|
+
* @param {object|undefined} query_filter
|
|
628
|
+
* @param {object|undefined} update_definition
|
|
629
|
+
* @returns {Promise<object|null>}
|
|
630
|
+
*/
|
|
631
|
+
Model.update_one = async function (query_filter, update_definition) {
|
|
632
|
+
const model = this;
|
|
633
|
+
const row = await exec_update_one(model, query_filter, update_definition);
|
|
634
|
+
|
|
635
|
+
if(row) {
|
|
636
|
+
return model.hydrate(row);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return null;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
Model.delete_one = async function (query_filter) {
|
|
643
|
+
const model = this;
|
|
644
|
+
const row = await exec_delete_one(model, query_filter);
|
|
645
|
+
|
|
646
|
+
if(row) {
|
|
647
|
+
return model.hydrate(row);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return null;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
/*
|
|
654
|
+
* SCHEMA / MIGRATION
|
|
655
|
+
*/
|
|
656
|
+
Model.ensure_model = async function () {
|
|
657
|
+
const model = this;
|
|
658
|
+
|
|
659
|
+
await model.ensure_table();
|
|
660
|
+
|
|
661
|
+
if(!model.schema.auto_index) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
await model.ensure_indexes();
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
Model.ensure_table = async function () {
|
|
669
|
+
const model = this;
|
|
670
|
+
const model_options = model.options;
|
|
671
|
+
const table_name = model_options.table_name;
|
|
672
|
+
const data_column = model_options.data_column;
|
|
673
|
+
const id_strategy = model.schema.id_strategy;
|
|
674
|
+
const server_capabilities = model.connection?.server_capabilities;
|
|
675
|
+
|
|
676
|
+
// Some id strategies depend on server-side support, so verify capability before creating the table.
|
|
677
|
+
if(id_strategy === ID_STRATEGY.uuidv7 && server_capabilities) {
|
|
678
|
+
assert_id_strategy_capability(id_strategy, server_capabilities);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
await ensure_table_sql({
|
|
682
|
+
table_name,
|
|
683
|
+
data_column,
|
|
684
|
+
id_strategy,
|
|
685
|
+
connection: model.connection
|
|
686
|
+
});
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
Model.ensure_indexes = async function () {
|
|
690
|
+
const model = this;
|
|
691
|
+
const model_options = model.options;
|
|
692
|
+
const schema_indexes = resolve_schema_indexes(model.schema);
|
|
693
|
+
|
|
694
|
+
if(model.state.indexes_ensured) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
for(const index_definition of schema_indexes) {
|
|
699
|
+
await ensure_index_sql({
|
|
700
|
+
table_name: model_options.table_name,
|
|
701
|
+
index_definition,
|
|
702
|
+
data_column: model_options.data_column,
|
|
703
|
+
connection: model.connection
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
model.state.indexes_ensured = true;
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
/*
|
|
711
|
+
* QUERY READS
|
|
712
|
+
*/
|
|
713
|
+
Model.find = function (query_filter) {
|
|
714
|
+
return new QueryBuilder(this, 'find', query_filter || {});
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
Model.find_one = function (query_filter) {
|
|
718
|
+
return new QueryBuilder(this, 'find_one', query_filter || {});
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
Model.find_by_id = function (id_value) {
|
|
722
|
+
return new QueryBuilder(this, 'find_one', {id: id_value});
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
Model.count_documents = function (query_filter) {
|
|
726
|
+
return new QueryBuilder(this, 'count_documents', query_filter || {});
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/*
|
|
730
|
+
* MODEL HELPERS
|
|
731
|
+
*/
|
|
732
|
+
|
|
733
|
+
// Resolve one explicit document path into the matching schema field path.
|
|
734
|
+
function resolve_field_type_path(schema, resolved_path) {
|
|
735
|
+
const path_segments = split_dot_path(resolved_path);
|
|
736
|
+
const root_key = path_segments[0];
|
|
737
|
+
|
|
738
|
+
if(base_field_keys.has(root_key) || schema.get_extra_slugs().includes(root_key)) {
|
|
739
|
+
return resolved_path;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if(root_key === schema.get_default_slug()) {
|
|
743
|
+
return path_segments.slice(1).join('.');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return resolved_path;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Extract root base fields and route registered slug roots out of payload data.
|
|
751
|
+
*
|
|
752
|
+
* @param {Function} model
|
|
753
|
+
* @param {*} input_data External input data.
|
|
754
|
+
* @returns {object}
|
|
755
|
+
*/
|
|
756
|
+
function extract_from_document_fields(model, input_data) {
|
|
757
|
+
const schema = model.schema;
|
|
758
|
+
const default_slug = schema.get_default_slug();
|
|
759
|
+
|
|
760
|
+
// 1. Guard against primitives, null, and arrays
|
|
761
|
+
if(is_not_object(input_data)) {
|
|
762
|
+
return {
|
|
763
|
+
[default_slug]: {}
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 2. Convert class instances, models, and custom objects into a plain object
|
|
768
|
+
let source = input_data;
|
|
769
|
+
|
|
770
|
+
// Its a class or constructor instance of some sort
|
|
771
|
+
if(!is_plain_object(source)) {
|
|
772
|
+
const serialize = get_callable(source, 'to_json', 'toJSON', to_plain_object);
|
|
773
|
+
|
|
774
|
+
// Execute using .call() to pass 'this' for class methods, and 'source' as the first arg for utilities.
|
|
775
|
+
source = serialize.call(source, source);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Double check plain-object conversion succeeded before proceeding
|
|
779
|
+
if(!is_plain_object(source)) {
|
|
780
|
+
return {
|
|
781
|
+
[default_slug]: {}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// 3. Expand dotted payload keys at the public input boundary.
|
|
786
|
+
source = expand_dot_paths(source);
|
|
787
|
+
|
|
788
|
+
const registered_slug_keys = schema.get_extra_slugs();
|
|
789
|
+
const registered_slug_set = new Set(registered_slug_keys);
|
|
790
|
+
const document = {
|
|
791
|
+
[default_slug]: {}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// 4. Route root keys in one pass into base fields, registered slugs, or the default slug.
|
|
795
|
+
for(const [key, value] of Object.entries(source)) {
|
|
796
|
+
if(base_field_keys.has(key)) {
|
|
797
|
+
document[key] = value;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if(unsafe_from_keys.has(key)) {
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if(registered_slug_set.has(key)) {
|
|
806
|
+
document[key] = deep_clone(value);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
document[default_slug][key] = value;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 5. Deep-clone the default slug payload so the normalized document owns its state.
|
|
814
|
+
document[default_slug] = deep_clone(document[default_slug]);
|
|
815
|
+
|
|
816
|
+
return document;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Extract row-like input without applying payload-style dot-path routing.
|
|
821
|
+
*
|
|
822
|
+
* @param {Function} model
|
|
823
|
+
* @param {*} input_data
|
|
824
|
+
* @returns {object|null}
|
|
825
|
+
*/
|
|
826
|
+
function extract_hydrated_document_fields(model, input_data) {
|
|
827
|
+
if(is_not_object(input_data)) {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let source = input_data;
|
|
832
|
+
|
|
833
|
+
if(!is_plain_object(source)) {
|
|
834
|
+
const serialize = get_callable(source, 'to_json', 'toJSON', to_plain_object);
|
|
835
|
+
source = serialize.call(source, source);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if(!is_plain_object(source)) {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const schema = model.schema;
|
|
843
|
+
const default_slug = schema.get_default_slug();
|
|
844
|
+
const slugs = schema.get_slugs();
|
|
845
|
+
const document = {};
|
|
846
|
+
|
|
847
|
+
for(const base_field_key of base_field_keys) {
|
|
848
|
+
if(has_own(source, base_field_key)) {
|
|
849
|
+
document[base_field_key] = source[base_field_key];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
for(const slug_key of slugs) {
|
|
854
|
+
const slug_value = is_plain_object(source[slug_key]) ? source[slug_key] : {};
|
|
855
|
+
document[slug_key] = deep_clone(slug_value);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
for(const unsafe_key of unsafe_from_keys) {
|
|
859
|
+
delete document[default_slug][unsafe_key];
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return document;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function apply_timestamps(document) {
|
|
866
|
+
const now = new Date();
|
|
867
|
+
|
|
868
|
+
if(document.created_at === undefined || document.created_at === null) {
|
|
869
|
+
document.created_at = now;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
document.updated_at = now;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export default Model;
|