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,392 @@
1
+ # Model API
2
+
3
+ Models are created through the owning connection:
4
+
5
+ ```js
6
+ const connection = await JsonBadger.connect(uri, options);
7
+ const user_model = connection.model({name: 'User', schema: user_schema, table_name: 'users'});
8
+ ```
9
+
10
+ ## TOC
11
+
12
+ - [Model API](#model-api)
13
+ - [Construction](#construction)
14
+ - [Ownership Boundary](#ownership-boundary)
15
+ - [Static Methods](#static-methods)
16
+ - [Model.from](#modelfrom)
17
+ - [Model.hydrate](#modelhydrate)
18
+ - [Model.cast](#modelcast)
19
+ - [Instance Methods](#instance-methods)
20
+ - [Related Docs](#related-docs)
21
+
22
+ ## Construction
23
+
24
+ Build new documents with `Model.from(...)`:
25
+
26
+ ```js
27
+ const user_document = user_model.from({
28
+ name: 'john',
29
+ age: 30
30
+ });
31
+ ```
32
+
33
+ Hydrate persisted row-like data with `Model.hydrate(...)`:
34
+
35
+ ```js
36
+ const hydrated_user = user_model.hydrate({
37
+ id: '7',
38
+ data: {
39
+ name: 'maria'
40
+ },
41
+ created_at: '2026-03-03T08:00:00.000Z',
42
+ updated_at: '2026-03-03T09:00:00.000Z'
43
+ });
44
+ ```
45
+
46
+ > **Note:** Prefer `Model.from(...)` and `Model.hydrate(...)` over direct `new Model(...)` construction. Those entry points conform the document envelope, then apply schema defaults, casting, and validation before returning the document instance.
47
+ >
48
+ > Direct `new user_model(document)` construction remains available as a low-level path. It bypasses lifecycle until you call `doc.$conform_document(...)`, `doc.$apply_defaults(...)`, `doc.$cast(...)`, `doc.$validate(...)`, or `doc.$normalize(...)` manually.
49
+
50
+ ## Ownership Boundary
51
+
52
+ `Model` owns how a schema is persisted and used at runtime.
53
+
54
+ It is the right place for:
55
+
56
+ 1. `table_name`, `data_column`, and related storage options
57
+ 2. Startup/bootstrap helpers such as `ensure_table()`, `ensure_indexes()`, and `ensure_model()`
58
+ 3. Query and update entry points
59
+ 4. Document construction and row hydration
60
+
61
+ `Model` consumes a `Schema`; it does not replace it.
62
+
63
+ ## Static Methods
64
+
65
+ - `Model.create(document_or_list)`
66
+ - validates and saves one or more documents
67
+ - `Model.find(filter)`
68
+ - returns a query builder
69
+ - `Model.find_one(filter)`
70
+ - returns a query builder that resolves to one document or `null`
71
+ - `Model.find_by_id(id_value)`
72
+ - returns a query builder for one document by top-level `id`
73
+ - `Model.count_documents(filter)`
74
+ - returns a query builder that resolves to a count
75
+ - `Model.insert_one(input_data)`
76
+ - inserts one plain payload input and returns the persisted document
77
+ - `Model.update_one(filter, update_definition)`
78
+ - updates one matching row and returns the updated document or `null`
79
+ - `Model.delete_one(filter)`
80
+ - deletes one matching row and returns the deleted document or `null`
81
+ - `Model.ensure_table()`
82
+ - creates the backing table during startup/bootstrap setup
83
+ - `Model.ensure_indexes()`
84
+ - creates declared indexes during startup/bootstrap setup
85
+ - `Model.ensure_model()`
86
+ - runs table setup and, when `schema.auto_index` is enabled, declared indexes
87
+ - `Model.from(input_data, options?)`
88
+ - builds a new document from external payload input without marking it persisted
89
+ - `Model.hydrate(input_data, options?)`
90
+ - builds a persisted document from existing row-like data with no modified paths
91
+ - `Model.cast(document)`
92
+ - delegates to `schema.cast(document)`
93
+
94
+ Example:
95
+
96
+ ```js
97
+ const saved_user = await user_model.create({
98
+ name: 'maria',
99
+ age: 29
100
+ });
101
+
102
+ const found_user = await user_model.find_one({name: 'maria'}).exec();
103
+ ```
104
+
105
+ > **Note:** `Model.insert_one(...)` accepts plain input only. If you already have a document instance, use `doc.insert()` or `doc.save()`.
106
+
107
+ ## Model.from
108
+
109
+ Use `Model.from(...)` when the source is external payload input and you want a new document:
110
+
111
+ ```js
112
+ const imported_user = user_model.from({
113
+ name: ' maria ',
114
+ age: '29',
115
+ created_at: '2026-03-03T08:00:00.000Z'
116
+ });
117
+ ```
118
+
119
+ Behavior:
120
+ - returns a new document instance
121
+ - keeps `is_new = true`
122
+ - leaves no paths marked as modified after lifecycle normalization
123
+ - conforms the document envelope, then applies schema defaults, casting, and validation before returning
124
+ - expands dotted payload keys at the input boundary
125
+ - routes root keys listed in `schema.options.slugs` out of the default slug
126
+ - keeps every other non-base root key inside the default slug
127
+ - does not interpret payload input as a persisted row envelope
128
+
129
+ Input flow:
130
+ 1. check whether the input is object-like
131
+ 2. serialize non-plain objects through `to_json`, `toJSON`, or `to_plain_object(...)`
132
+ 3. expand dotted payload keys into nested objects
133
+ 4. route registered extra slug roots out of the default slug
134
+ 5. keep every remaining root key in the default slug
135
+ 6. build the document instance
136
+ 7. conform the document envelope, then apply schema defaults, casting, and validation
137
+ 8. clear tracker changes so the normalized state becomes the baseline
138
+
139
+ Example with a custom default slug:
140
+
141
+ ```js
142
+ const user_schema = new JsonBadger.Schema({
143
+ name: String,
144
+ settings: {
145
+ theme: String
146
+ }
147
+ }, {
148
+ default_slug: 'payload',
149
+ slugs: ['settings']
150
+ });
151
+
152
+ const user_model = connection.model({
153
+ name: 'User',
154
+ schema: user_schema,
155
+ table_name: 'users'
156
+ });
157
+
158
+ const imported_user = user_model.from({
159
+ id: '7',
160
+ created_at: '2026-03-03T08:00:00.000Z',
161
+ name: 'maria',
162
+ settings: {
163
+ theme: 'dark'
164
+ }
165
+ });
166
+ ```
167
+
168
+ Result:
169
+ - `imported_user.document.id === '7'`
170
+ - `imported_user.document.created_at instanceof Date`
171
+ - `imported_user.document.payload.name === 'maria'`
172
+ - `imported_user.document.settings.theme === 'dark'`
173
+
174
+ ## Model.hydrate
175
+
176
+ Use `Model.hydrate(...)` when the source already represents a persisted row:
177
+
178
+ ```js
179
+ const hydrated_user = user_model.hydrate({
180
+ id: '7',
181
+ payload: {
182
+ name: 'maria',
183
+ age: '29'
184
+ },
185
+ settings: {
186
+ theme: 'dark'
187
+ },
188
+ created_at: '2026-03-03T08:00:00.000Z',
189
+ updated_at: '2026-03-03T09:00:00.000Z'
190
+ });
191
+ ```
192
+
193
+ Behavior:
194
+ - returns a document instance
195
+ - sets `is_new = false`
196
+ - leaves no paths marked as modified initially
197
+ - treats the outer object as the persisted row envelope
198
+ - reads the payload from `input_data[default_slug]`
199
+ - preserves registered extra slug objects as sibling roots
200
+ - ignores unregistered extra root slugs
201
+ - applies schema defaults, casting, and validation before rebasing tracker state
202
+ - does not expand dotted payload keys during hydration
203
+
204
+ Input flow:
205
+ 1. check whether the input is object-like
206
+ 2. serialize non-plain objects through `to_json`, `toJSON`, or `to_plain_object(...)`
207
+ 3. copy top-level base fields from the row
208
+ 4. read `schema.get_slugs()` from the row and default missing slug roots to `{}`
209
+ 5. build the document instance
210
+ 6. conform the document envelope, then apply schema defaults, casting, and validation
211
+ 7. clear tracker changes
212
+ 8. mark the document persisted
213
+
214
+ > **Note:** `Model.hydrate(...)` is strict about row shape. It does not rebuild the default slug from unrelated root keys.
215
+
216
+ ## Model.cast
217
+
218
+ Use `Model.cast(...)` when you want schema casting without building a document instance:
219
+
220
+ ```js
221
+ const casted_document = user_model.cast({
222
+ payload: {
223
+ age: '29'
224
+ },
225
+ settings: {
226
+ count: '7'
227
+ }
228
+ });
229
+ ```
230
+
231
+ Behavior:
232
+ - delegates to `schema.cast(...)`
233
+ - deep-clones the input document first
234
+ - casts existing base-field, default-slug, and extra-slug values
235
+ - does not validate
236
+ - does not conform
237
+
238
+ ## Instance Methods
239
+
240
+ - `doc.save()`
241
+ - inserts a new row or updates an existing persisted document
242
+ - `doc.insert()`
243
+ - inserts the current document as a new row
244
+ - `doc.update()`
245
+ - updates the current persisted document by `id`
246
+ - `doc.get(path_name)`
247
+ - resolves aliases and reads the exact path from `doc.document`
248
+ - `doc.set(path_name, value)`
249
+ - resolves aliases, applies schema setter/cast logic, and writes the exact path into `doc.document`
250
+ - `doc.$apply_defaults(options?)`
251
+ - fills missing paths from schema defaults on the current tracked document
252
+ - `doc.$conform_document(options?)`
253
+ - removes unknown envelope keys from the current tracked document according to schema slugs and paths
254
+ - `doc.$cast(options?)`
255
+ - applies schema setter/cast logic across the current tracked document
256
+ - `doc.$validate(options?)`
257
+ - validates the current document envelope against the schema
258
+ - `doc.$normalize(options?)`
259
+ - runs `$conform_document(...)`, `$apply_defaults(...)`, `$cast(...)`, and `$validate(...)` in order
260
+ - `doc.bind_document(target)`
261
+ - binds live forwarding properties onto `target` from the current document instance
262
+ - `doc.rebase(reference)`
263
+ - replaces the current document state from another `Model` or `Document` and clears tracked changes
264
+ - `doc.id`
265
+ - `doc.created_at`
266
+ - `doc.updated_at`
267
+ - `doc.timestamps`
268
+
269
+ Example:
270
+
271
+ ```js
272
+ const doc = user_model.from({
273
+ name: 'john',
274
+ profile: {city: 'Miami'}
275
+ });
276
+
277
+ const default_slug = user_model.schema.get_default_slug();
278
+
279
+ doc.set(default_slug + '.profile.city', 'Orlando');
280
+ const city = doc.get(default_slug + '.profile.city');
281
+
282
+ await doc.save();
283
+ ```
284
+
285
+ Low-level constructor path with manual lifecycle:
286
+
287
+ ```js
288
+ const doc = new user_model({
289
+ payload: {
290
+ age: '29'
291
+ }
292
+ });
293
+
294
+ doc.$normalize({mode: 'from'});
295
+ ```
296
+
297
+ Behavior:
298
+ - direct `new user_model(document)` keeps the raw tracked state as given
299
+ - conform, defaults, casting, and validation do not run automatically on that low-level path
300
+ - manual lifecycle methods are available when you intentionally want that control
301
+
302
+ `doc.bind_document(target)` projects live document-backed fields onto another object, such as a domain entity wrapper.
303
+
304
+ ```js
305
+ const doc = user_model.hydrate({
306
+ id: '7',
307
+ payload: {
308
+ name: 'maria',
309
+ profile: {city: 'Miami'}
310
+ },
311
+ settings: {
312
+ theme: 'dark'
313
+ }
314
+ });
315
+
316
+ const entity = {};
317
+ doc.bind_document(entity);
318
+
319
+ entity.name; // 'maria'
320
+ entity.profile.city = 'Orlando';
321
+ entity.settings.theme = 'light';
322
+ ```
323
+
324
+ Behavior:
325
+ - returns the same `target`
326
+ - flattens default-slug root fields onto the target root
327
+ - keeps extra slugs nested at their slug root
328
+ - returns live tracked objects, not snapshots
329
+ - throws if `target` already owns a conflicting property name
330
+
331
+ > **Note:** `bind_document(...)` does not create or require `target.model`. It binds directly to the current document instance. To rebind a different document later, clear or replace the old bound properties first, then call `next_doc.bind_document(target)` again.
332
+
333
+ `doc.rebase(reference)` is useful when you want to keep the same outer object but adopt a fresh persisted document state.
334
+
335
+ Scenario A: keep a bound entity wrapper and adopt the saved state.
336
+
337
+ ```js
338
+ const doc = user_model.from({
339
+ name: 'maria',
340
+ profile: {city: 'Miami'}
341
+ });
342
+
343
+ const entity = {};
344
+ doc.bind_document(entity);
345
+
346
+ entity.profile.city = 'Orlando';
347
+
348
+ const saved_doc = await doc.save();
349
+ doc.rebase(saved_doc);
350
+ ```
351
+
352
+ Scenario B: refresh the current instance from a newer persisted copy.
353
+
354
+ ```js
355
+ const current_doc = await user_model.find_one({id: user_id}).exec();
356
+ const fresh_doc = await user_model.find_one({id: user_id}).exec();
357
+
358
+ current_doc.rebase(fresh_doc);
359
+ ```
360
+
361
+ Scenario C: adopt a trusted `Document` returned by a custom persistence path.
362
+
363
+ ```js
364
+ const next_document = custom_save_flow(doc.document);
365
+ doc.rebase(next_document);
366
+ ```
367
+
368
+ Behavior:
369
+ - accepts only a `Model` or `Document` reference
370
+ - sets `doc.is_new = false`
371
+ - replaces the current `doc.document` state
372
+ - clears pending tracker changes after rebasing
373
+
374
+ > **Note:** Use `Model.hydrate(...)` for plain row-like input. `rebase(...)` is for adopting an already-built `Model` or `Document`.
375
+
376
+ Direct document access:
377
+
378
+ ```js
379
+ const default_slug = user_model.schema.get_default_slug();
380
+ const payload = doc.document[default_slug];
381
+ ```
382
+
383
+ > **Note:** Direct `doc.document[...]` mutation is still allowed, but it bypasses `doc.set(...)` and assignment-time casting.
384
+
385
+ ## Related Docs
386
+
387
+ - [`connection.md`](connection.md)
388
+ - [`document.md`](document.md)
389
+ - [`schema.md`](schema.md)
390
+ - [`query-builder.md`](query-builder.md)
391
+ - [`../lifecycle.md`](../lifecycle.md)
392
+ - [`../examples.md`](../examples.md)
@@ -0,0 +1,81 @@
1
+ # Query Builder API
2
+
3
+ ## TOC
4
+
5
+ - [Query Builder API](#query-builder-api)
6
+ - [Builder Chain](#builder-chain)
7
+ - [Execution](#execution)
8
+ - [Supported Filter Families](#supported-filter-families)
9
+ - [Base-field Query Rules](#base-field-query-rules)
10
+ - [JSON Key Existence](#json-key-existence)
11
+ - [JSONPath](#jsonpath)
12
+
13
+ ## Builder Chain
14
+
15
+ - `.where(filter)`
16
+ - `.sort(sort)`
17
+ - `.limit(limit)`
18
+ - `.skip(skip)`
19
+ - `.exec()`
20
+
21
+ ## Execution
22
+
23
+ ```js
24
+ const result = await user_model.find({status: 'active'}).sort({created_at: -1}).limit(10).exec();
25
+ ```
26
+
27
+ Filter objects must be plain JSON-like objects:
28
+ - use object literals for operator objects like `{age: {$gte: 18}}`
29
+ - use plain nested objects for JSON containment filters
30
+ - use plain objects instead of custom class instances for operator objects
31
+
32
+ > **Note:** Base-field operator objects and nested filter objects only accept plain objects with a default or null prototype.
33
+
34
+ Read/query execution:
35
+ - `find(...).exec()`
36
+ - `find_one(...).exec()`
37
+ - `find_by_id(...).exec()`
38
+ - `count_documents(...).exec()`
39
+
40
+ > **Note:** Query builders are not thenables. Call `.exec()`.
41
+
42
+ ## Supported Filter Families
43
+
44
+ - scalar comparisons: direct equality, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`
45
+ - sets/arrays: `$in`, `$nin`, `$all`, `$size`, `$contains`, `$elem_match`
46
+ - regex: `$regex` and `$options`
47
+ - JSONB key existence: `$has_key`, `$has_any_keys`, `$has_all_keys`
48
+ - JSONPath: `$json_path_exists`, `$json_path_match`
49
+
50
+ See [`../query-translation.md`](../query-translation.md) for PostgreSQL operator/function mapping.
51
+
52
+ ## Base-field Query Rules
53
+
54
+ Top-level base fields:
55
+ - `id`
56
+ - `created_at`
57
+ - `updated_at`
58
+
59
+ Rules:
60
+ - top-level base fields map to row columns
61
+ - dotted base-field paths are rejected
62
+
63
+ Operator matrix:
64
+ - `id`: direct equality, `$eq`, `$ne`, `$in`, `$nin`
65
+ - `created_at` / `updated_at`: direct equality, `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`
66
+ - unsupported on base fields: `$regex`, `$contains`, `$all`, `$size`, `$has_key`, `$has_any_keys`, `$has_all_keys`, `$json_path_exists`, `$json_path_match`, `$elem_match`
67
+
68
+ ## JSON Key Existence
69
+
70
+ - `$has_key` maps to PostgreSQL `?`
71
+ - `$has_any_keys` maps to PostgreSQL `?|`
72
+ - `$has_all_keys` maps to PostgreSQL `?&`
73
+
74
+ When used with a nested field path, JsonBadger extracts that nested JSONB value first, then applies the existence operator to the extracted value.
75
+
76
+ ## JSONPath
77
+
78
+ - `$json_path_exists` maps to `@?`
79
+ - `$json_path_match` maps to `@@`
80
+
81
+ JsonBadger binds the JSONPath string as a `::jsonpath` parameter.
@@ -0,0 +1,204 @@
1
+ # Schema API
2
+
3
+ ## TOC
4
+
5
+ - [Schema API](#schema-api)
6
+ - [Constructor](#constructor)
7
+ - [Ownership Boundary](#ownership-boundary)
8
+ - [Core Methods](#core-methods)
9
+ - [Slug Access](#slug-access)
10
+ - [Casting and Validation](#casting-and-validation)
11
+ - [create_index](#create_index)
12
+ - [add_method](#add_method)
13
+ - [Path-level Index Sugar](#path-level-index-sugar)
14
+
15
+ ## Constructor
16
+
17
+ ```js
18
+ const user_schema = new JsonBadger.Schema(definition, options);
19
+ ```
20
+
21
+ ## Ownership Boundary
22
+
23
+ `Schema` defines what a document looks like.
24
+
25
+ It is the right place for:
26
+
27
+ 1. Field definitions
28
+ 2. Validation and path introspection
29
+ 3. Schema-declared indexes
30
+ 4. Schema-level document methods
31
+ 5. Slug ownership and validator domains
32
+
33
+ Use `Schema` to define document structure and behavior. Specify table names, connection details, and other storage options later when you register the model through `connection.model(...)`.
34
+
35
+ `definition` must be a plain JSON-like object:
36
+ - use object literals for nested schema branches
37
+ - use field definitions like `{type: String, required: true}`
38
+ - avoid custom class instances for schema definition objects
39
+
40
+ > **Note:** JsonBadger treats only default-prototype or null-prototype objects as plain schema objects. Custom instances are not parsed as nested schema branches.
41
+
42
+ ## Core Methods
43
+
44
+ - `validate(document)`
45
+ - `validate_base_fields(document)`
46
+ - `cast(document)`
47
+ - `conform(document)`
48
+ - `get_path(path)`
49
+ - `get_path_type(path)`
50
+ - `is_array_root(path)`
51
+ - `create_index(index_definition)`
52
+ - `get_indexes()`
53
+ - `add_method(name, method_implementation)`
54
+
55
+ Base-field notes:
56
+ - `id`, `created_at`, and `updated_at` exist in schema/runtime introspection
57
+ - if user schema declares those base fields, user definitions are preserved
58
+ - `validate(document)` validates the full document envelope, not just the default slug
59
+ - when a schema registers extra root slugs through `schema.options.slugs`, validation fans out across those document roots
60
+
61
+ ## Slug Access
62
+
63
+ Read slug ownership directly from the schema:
64
+
65
+ ```js
66
+ const default_slug = schema.get_default_slug();
67
+ const extra_slugs = schema.get_extra_slugs();
68
+ const all_slugs = schema.get_slugs();
69
+ ```
70
+
71
+ Rules:
72
+ - `get_default_slug()` returns the catch-all payload root
73
+ - `get_extra_slugs()` returns only explicitly registered sibling roots
74
+ - `get_slugs()` returns `[default_slug, ...extra_slugs]`
75
+ - duplicate extra slugs are collapsed during schema option normalization
76
+
77
+ ## Casting and Validation
78
+
79
+ Validate a full document envelope:
80
+
81
+ ```js
82
+ schema.validate({
83
+ id: '0194f028-579a-7b5b-8107-b9ad31395f43',
84
+ payload: {
85
+ name: 'maria'
86
+ },
87
+ settings: {
88
+ theme: 'dark'
89
+ }
90
+ });
91
+ ```
92
+
93
+ Validate only root base fields:
94
+
95
+ ```js
96
+ schema.validate_base_fields({
97
+ id: '0194f028-579a-7b5b-8107-b9ad31395f43',
98
+ created_at: '2026-03-03T08:00:00.000Z',
99
+ updated_at: '2026-03-03T09:00:00.000Z'
100
+ });
101
+ ```
102
+
103
+ Cast a document envelope without validating or conforming:
104
+
105
+ ```js
106
+ const casted_document = schema.cast({
107
+ payload: {
108
+ age: '29'
109
+ },
110
+ settings: {
111
+ count: '7'
112
+ }
113
+ });
114
+ ```
115
+
116
+ Behavior:
117
+ - `schema.cast(...)` deep-clones the input first
118
+ - casts existing base-field, default-slug, and extra-slug values
119
+ - leaves missing paths untouched
120
+ - does not validate
121
+ - does not conform
122
+
123
+ Conform one document envelope to the schema-owned allowed tree:
124
+
125
+ ```js
126
+ const document = {
127
+ payload: {
128
+ name: 'nell',
129
+ unknown_key: true
130
+ },
131
+ settings: {
132
+ theme: 'dark',
133
+ rogue: true
134
+ }
135
+ };
136
+
137
+ schema.conform(document);
138
+ ```
139
+
140
+ After conforming:
141
+ - known keys remain
142
+ - unknown keys are removed
143
+
144
+ ## create_index
145
+
146
+ GIN single-path index:
147
+
148
+ ```js
149
+ schema.create_index({using: 'gin', path: 'profile.city', name: 'idx_users_city_gin'});
150
+ ```
151
+
152
+ BTREE single-path index:
153
+
154
+ ```js
155
+ schema.create_index({using: 'btree', path: 'age', order: -1, unique: true});
156
+ ```
157
+
158
+ BTREE compound index:
159
+
160
+ ```js
161
+ schema.create_index({using: 'btree', paths: {name: 1, type: -1}, unique: true, name: 'idx_name_type'});
162
+ ```
163
+
164
+ Rules:
165
+ - `using: 'gin'` requires `path`
166
+ - `using: 'gin'` ignores `order` and `unique`
167
+ - `using: 'btree'` requires `path` or `paths`
168
+ - `order` is only valid when using a single `path` with `using: 'btree'`
169
+
170
+ ## add_method
171
+
172
+ Install a custom document-instance method onto models generated from the schema.
173
+
174
+ ```js
175
+ schema.add_method('full_name', function () {
176
+ const default_slug = this.constructor.schema.get_default_slug();
177
+ const payload = this.document[default_slug];
178
+ return payload.first_name + ' ' + payload.last_name;
179
+ });
180
+ ```
181
+
182
+ Rules:
183
+ - `name` must be a valid identifier
184
+ - `method_implementation` must be a function
185
+ - duplicate schema method names are rejected
186
+ - names that conflict with built-in document properties are rejected when the model is created
187
+
188
+ ## Path-level Index Sugar
189
+
190
+ Examples:
191
+
192
+ ```js
193
+ {
194
+ name: {type: String, index: true},
195
+ age: {type: Number, index: 1},
196
+ score: {type: Number, index: -1},
197
+ email: {type: String, index: {unique: true, name: 'idx_users_email_unique'}},
198
+ nick_name: {type: String, index: {using: 'gin'}}
199
+ }
200
+ ```
201
+
202
+ Normalization notes:
203
+ - object-form `index` defaults to `btree` unless `using` says otherwise
204
+ - invalid or unsupported inline index values are ignored permissively