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.
Files changed (123) hide show
  1. package/README.md +36 -18
  2. package/docs/api/connection.md +144 -0
  3. package/docs/api/delta-tracker.md +106 -0
  4. package/docs/api/document.md +77 -0
  5. package/docs/api/field-types.md +329 -0
  6. package/docs/api/index.md +35 -0
  7. package/docs/api/model.md +392 -0
  8. package/docs/api/query-builder.md +81 -0
  9. package/docs/api/schema.md +204 -0
  10. package/docs/architecture-flow.md +397 -0
  11. package/docs/examples.md +495 -218
  12. package/docs/jsonb-ops.md +171 -0
  13. package/docs/lifecycle/model-compilation.md +111 -0
  14. package/docs/lifecycle.md +146 -0
  15. package/docs/query-translation.md +11 -10
  16. package/package.json +10 -3
  17. package/src/connection/connect.js +12 -17
  18. package/src/connection/connection.js +128 -0
  19. package/src/connection/server-capabilities.js +60 -59
  20. package/src/constants/defaults.js +32 -19
  21. package/src/constants/{id-strategies.js → id-strategy.js} +28 -29
  22. package/src/constants/intake-mode.js +8 -0
  23. package/src/debug/debug-logger.js +17 -15
  24. package/src/errors/model-overwrite-error.js +25 -0
  25. package/src/errors/query-error.js +25 -23
  26. package/src/errors/validation-error.js +25 -23
  27. package/src/field-types/base-field-type.js +137 -140
  28. package/src/field-types/builtins/advanced.js +365 -365
  29. package/src/field-types/builtins/index.js +579 -585
  30. package/src/field-types/field-type-namespace.js +9 -0
  31. package/src/field-types/registry.js +149 -122
  32. package/src/index.js +26 -36
  33. package/src/migration/ensure-index.js +157 -154
  34. package/src/migration/ensure-schema.js +27 -15
  35. package/src/migration/ensure-table.js +44 -31
  36. package/src/migration/schema-indexes-resolver.js +8 -6
  37. package/src/model/document-instance.js +29 -540
  38. package/src/model/document.js +60 -0
  39. package/src/model/factory/constants.js +36 -0
  40. package/src/model/factory/index.js +58 -0
  41. package/src/model/model.js +875 -0
  42. package/src/model/operations/delete-one.js +39 -0
  43. package/src/model/operations/insert-one.js +35 -0
  44. package/src/model/operations/query-builder.js +132 -0
  45. package/src/model/operations/update-one.js +333 -0
  46. package/src/model/state.js +34 -0
  47. package/src/schema/field-definition-parser.js +213 -218
  48. package/src/schema/path-introspection.js +87 -82
  49. package/src/schema/schema-compiler.js +126 -212
  50. package/src/schema/schema.js +621 -138
  51. package/src/sql/index.js +17 -0
  52. package/src/sql/jsonb/ops.js +153 -0
  53. package/src/{query → sql/jsonb}/path-parser.js +54 -43
  54. package/src/sql/jsonb/read/elem-match.js +133 -0
  55. package/src/{query → sql/jsonb/read}/operators/contains.js +13 -7
  56. package/src/sql/jsonb/read/operators/elem-match.js +9 -0
  57. package/src/{query → sql/jsonb/read}/operators/has-all-keys.js +17 -11
  58. package/src/{query → sql/jsonb/read}/operators/has-any-keys.js +18 -11
  59. package/src/sql/jsonb/read/operators/has-key.js +12 -0
  60. package/src/{query → sql/jsonb/read}/operators/jsonpath-exists.js +22 -15
  61. package/src/{query → sql/jsonb/read}/operators/jsonpath-match.js +22 -15
  62. package/src/{query → sql/jsonb/read}/operators/size.js +23 -16
  63. package/src/sql/parameter-binder.js +18 -13
  64. package/src/sql/read/build-count-query.js +12 -0
  65. package/src/sql/read/build-find-query.js +25 -0
  66. package/src/sql/read/limit-skip.js +21 -0
  67. package/src/sql/read/sort.js +85 -0
  68. package/src/sql/read/where/base-fields.js +310 -0
  69. package/src/sql/read/where/casting.js +90 -0
  70. package/src/sql/read/where/context.js +79 -0
  71. package/src/sql/read/where/field-clause.js +58 -0
  72. package/src/sql/read/where/index.js +38 -0
  73. package/src/sql/read/where/operator-entries.js +29 -0
  74. package/src/{query → sql/read/where}/operators/all.js +16 -10
  75. package/src/sql/read/where/operators/eq.js +12 -0
  76. package/src/{query → sql/read/where}/operators/gt.js +23 -16
  77. package/src/{query → sql/read/where}/operators/gte.js +23 -16
  78. package/src/{query → sql/read/where}/operators/in.js +18 -12
  79. package/src/sql/read/where/operators/index.js +40 -0
  80. package/src/{query → sql/read/where}/operators/lt.js +23 -16
  81. package/src/{query → sql/read/where}/operators/lte.js +23 -16
  82. package/src/sql/read/where/operators/ne.js +12 -0
  83. package/src/{query → sql/read/where}/operators/nin.js +18 -12
  84. package/src/{query → sql/read/where}/operators/regex.js +14 -8
  85. package/src/sql/read/where/operators.js +126 -0
  86. package/src/sql/read/where/text-operators.js +83 -0
  87. package/src/sql/run.js +46 -0
  88. package/src/sql/write/build-delete-query.js +33 -0
  89. package/src/sql/write/build-insert-query.js +42 -0
  90. package/src/sql/write/build-update-query.js +65 -0
  91. package/src/utils/assert.js +34 -27
  92. package/src/utils/delta-tracker/.archive/1 tracker-redesign-codex-v2.md +250 -0
  93. package/src/utils/delta-tracker/.archive/1 tracker-redesign-gemini.md +101 -0
  94. package/src/utils/delta-tracker/.archive/2 evaluation by gemini.txt +65 -0
  95. package/src/utils/delta-tracker/.archive/2 evaluation by grok.txt +39 -0
  96. package/src/utils/delta-tracker/.archive/3 gemini evaluate grok.txt +37 -0
  97. package/src/utils/delta-tracker/.archive/3 grok evaluate gemini.txt +63 -0
  98. package/src/utils/delta-tracker/.archive/4 gemini veredict.txt +16 -0
  99. package/src/utils/delta-tracker/.archive/index.1.js +587 -0
  100. package/src/utils/delta-tracker/.archive/index.2.js +612 -0
  101. package/src/utils/delta-tracker/index.js +592 -0
  102. package/src/utils/dirty-tracker/inline.js +335 -0
  103. package/src/utils/dirty-tracker/instance.js +414 -0
  104. package/src/utils/dirty-tracker/static.js +343 -0
  105. package/src/utils/json-safe.js +13 -9
  106. package/src/utils/object-path.js +227 -33
  107. package/src/utils/object.js +408 -168
  108. package/src/utils/string.js +55 -0
  109. package/src/utils/value.js +169 -30
  110. package/docs/api.md +0 -152
  111. package/src/connection/disconnect.js +0 -16
  112. package/src/connection/pool-store.js +0 -46
  113. package/src/model/model-factory.js +0 -555
  114. package/src/query/limit-skip-compiler.js +0 -31
  115. package/src/query/operators/elem-match.js +0 -3
  116. package/src/query/operators/eq.js +0 -6
  117. package/src/query/operators/has-key.js +0 -6
  118. package/src/query/operators/index.js +0 -60
  119. package/src/query/operators/ne.js +0 -6
  120. package/src/query/query-builder.js +0 -93
  121. package/src/query/sort-compiler.js +0 -30
  122. package/src/query/where-compiler.js +0 -477
  123. 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;