@atscript/utils-db 0.1.30 → 0.1.32

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/dist/index.cjs CHANGED
@@ -101,6 +101,78 @@ else update[key] = value;
101
101
  }
102
102
  }
103
103
 
104
+ //#endregion
105
+ //#region packages/utils-db/src/uniqu-select.ts
106
+ function _define_property$2(obj, key, value) {
107
+ if (key in obj) Object.defineProperty(obj, key, {
108
+ value,
109
+ enumerable: true,
110
+ configurable: true,
111
+ writable: true
112
+ });
113
+ else obj[key] = value;
114
+ return obj;
115
+ }
116
+ var UniquSelect = class {
117
+ /**
118
+ * Resolved inclusion array of field names.
119
+ * For exclusion form, inverts using `allFields` from constructor.
120
+ */ get asArray() {
121
+ if (this._arrayResolved) return this._array;
122
+ this._arrayResolved = true;
123
+ if (Array.isArray(this._raw)) {
124
+ this._array = this._raw;
125
+ return this._array;
126
+ }
127
+ const raw = this._raw;
128
+ const entries = Object.entries(raw);
129
+ if (entries.length === 0) return undefined;
130
+ if (entries[0][1] === 1) {
131
+ const result = [];
132
+ for (const entry of entries) if (entry[1] === 1) result.push(entry[0]);
133
+ this._array = result;
134
+ } else {
135
+ if (!this._allFields) return undefined;
136
+ const excluded = new Set();
137
+ for (const entry of entries) if (entry[1] === 0) excluded.add(entry[0]);
138
+ const result = [];
139
+ for (const field of this._allFields) if (!excluded.has(field)) result.push(field);
140
+ this._array = result;
141
+ }
142
+ return this._array;
143
+ }
144
+ /**
145
+ * Record projection preserving original semantics.
146
+ * Returns original object as-is if raw was object.
147
+ * Converts `string[]` to `{field: 1}` inclusion object.
148
+ */ get asProjection() {
149
+ if (this._projectionResolved) return this._projection;
150
+ this._projectionResolved = true;
151
+ if (!Array.isArray(this._raw)) {
152
+ const raw = this._raw;
153
+ if (Object.keys(raw).length === 0) return undefined;
154
+ this._projection = raw;
155
+ return this._projection;
156
+ }
157
+ const arr = this._raw;
158
+ if (arr.length === 0) return undefined;
159
+ const result = {};
160
+ for (const item of arr) result[item] = 1;
161
+ this._projection = result;
162
+ return this._projection;
163
+ }
164
+ constructor(raw, allFields) {
165
+ _define_property$2(this, "_raw", void 0);
166
+ _define_property$2(this, "_allFields", void 0);
167
+ _define_property$2(this, "_arrayResolved", false);
168
+ _define_property$2(this, "_array", void 0);
169
+ _define_property$2(this, "_projectionResolved", false);
170
+ _define_property$2(this, "_projection", void 0);
171
+ this._raw = raw;
172
+ this._allFields = allFields;
173
+ }
174
+ };
175
+
104
176
  //#endregion
105
177
  //#region packages/utils-db/src/db-table.ts
106
178
  function _define_property$1(obj, key, value) {
@@ -120,11 +192,18 @@ function resolveDesignType(fieldType) {
120
192
  if (fieldType.type.kind === "array") return "array";
121
193
  return "string";
122
194
  }
195
+ /** Coerces a storage value (0/1/null) back to a JS boolean. */ function toBool(value) {
196
+ if (value === null || value === undefined) return value;
197
+ return !!value;
198
+ }
123
199
  function indexKey(type, name) {
124
200
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
125
201
  return `${INDEX_PREFIX}${type}__${cleanName}`;
126
202
  }
127
203
  var AtscriptDbTable = class {
204
+ /** Returns the underlying adapter with its concrete type preserved. */ getAdapter() {
205
+ return this.adapter;
206
+ }
128
207
  /** The raw annotated type. */ get type() {
129
208
  return this._type;
130
209
  }
@@ -140,6 +219,29 @@ var AtscriptDbTable = class {
140
219
  this._flatten();
141
220
  return this._primaryKeys;
142
221
  }
222
+ /**
223
+ * Registers an additional primary key field.
224
+ * Useful for adapters (e.g., MongoDB) where `_id` is always the primary key
225
+ * even without an explicit `@meta.id` annotation.
226
+ *
227
+ * Typically called from {@link BaseDbAdapter.onFieldScanned}.
228
+ */ addPrimaryKey(field) {
229
+ if (!this._primaryKeys.includes(field)) this._primaryKeys.push(field);
230
+ }
231
+ /**
232
+ * Removes a field from the primary key list.
233
+ * Useful for adapters (e.g., MongoDB) where `@meta.id` fields should be
234
+ * unique indexes rather than part of the primary key.
235
+ */ removePrimaryKey(field) {
236
+ const idx = this._primaryKeys.indexOf(field);
237
+ if (idx >= 0) this._primaryKeys.splice(idx, 1);
238
+ }
239
+ /**
240
+ * Registers a field as having a unique constraint.
241
+ * Used by adapters to ensure `findById` falls back to this field.
242
+ */ addUniqueField(field) {
243
+ this._uniqueProps.add(field);
244
+ }
143
245
  /** Logical → physical column name mapping from `@db.column`. */ get columnMap() {
144
246
  this._flatten();
145
247
  return this._columnMap;
@@ -156,6 +258,14 @@ var AtscriptDbTable = class {
156
258
  this._flatten();
157
259
  return this._uniqueProps;
158
260
  }
261
+ /** Precomputed logical dot-path → physical column name map. */ get pathToPhysical() {
262
+ this._flatten();
263
+ return this._pathToPhysical;
264
+ }
265
+ /** Precomputed physical column name → logical dot-path map (inverse). */ get physicalToPath() {
266
+ this._flatten();
267
+ return this._physicalToPath;
268
+ }
159
269
  /** Descriptor for the primary ID field(s). */ getIdDescriptor() {
160
270
  this._flatten();
161
271
  return {
@@ -171,39 +281,38 @@ var AtscriptDbTable = class {
171
281
  this._flatten();
172
282
  if (!this._fieldDescriptors) {
173
283
  this._fieldDescriptors = [];
284
+ const skipFlattening = this._nestedObjects;
174
285
  for (const [path, type] of this._flatMap.entries()) {
175
286
  if (!path) continue;
287
+ if (!skipFlattening && this._flattenedParents.has(path)) continue;
288
+ if (!skipFlattening && this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
289
+ const isJson = this._jsonFields.has(path);
290
+ const isFlattened = !skipFlattening && this._findAncestorInSet(path, this._flattenedParents) !== undefined;
291
+ const designType = isJson ? "json" : resolveDesignType(type);
292
+ let storage;
293
+ if (skipFlattening) storage = "column";
294
+ else if (isJson) storage = "json";
295
+ else if (isFlattened) storage = "flattened";
296
+ else storage = "column";
297
+ const physicalName = skipFlattening ? this._columnMap.get(path) ?? path : this._pathToPhysical.get(path) ?? this._columnMap.get(path) ?? path;
176
298
  this._fieldDescriptors.push({
177
299
  path,
178
300
  type,
179
- physicalName: this._columnMap.get(path) ?? path,
180
- designType: resolveDesignType(type),
301
+ physicalName,
302
+ designType,
181
303
  optional: type.optional === true,
182
304
  isPrimaryKey: this._primaryKeys.includes(path),
183
305
  ignored: this._ignoredFields.has(path),
184
- defaultValue: this._defaults.get(path)
306
+ defaultValue: this._defaults.get(path),
307
+ storage,
308
+ flattenedFrom: isFlattened ? path : undefined
185
309
  });
186
310
  }
311
+ Object.freeze(this._fieldDescriptors);
187
312
  }
188
313
  return this._fieldDescriptors;
189
314
  }
190
315
  /**
191
- * Resolves `$select` from {@link UniqueryControls} to a list of field names.
192
- * - `undefined` → `undefined` (all fields)
193
- * - `string[]` → pass through
194
- * - `Record<K, 1>` → extract included keys
195
- * - `Record<K, 0>` → invert using known field names
196
- */ resolveProjection(select) {
197
- if (!select) return undefined;
198
- if (Array.isArray(select)) return select.length > 0 ? select : undefined;
199
- const entries = Object.entries(select);
200
- if (entries.length === 0) return undefined;
201
- const firstVal = entries[0][1];
202
- if (firstVal === 1) return entries.filter(([, v]) => v === 1).map(([k]) => k);
203
- const excluded = new Set(entries.filter(([, v]) => v === 0).map(([k]) => k));
204
- return this.fieldDescriptors.filter((f) => !f.ignored && !excluded.has(f.path)).map((f) => f.path);
205
- }
206
- /**
207
316
  * Creates a new validator with custom options.
208
317
  * Adapter plugins are NOT automatically included — use {@link getValidator}
209
318
  * for the standard validator with adapter plugins.
@@ -266,9 +375,9 @@ var AtscriptDbTable = class {
266
375
  const validator = this.getValidator("patch");
267
376
  if (!validator.validate(payload)) throw new Error("Validation failed for update");
268
377
  const filter = this._extractPrimaryKeyFilter(payload);
269
- if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(filter, payload);
378
+ if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(this._translateFilter(filter), payload);
270
379
  const update = decomposePatch(payload, this);
271
- return this.adapter.updateOne(filter, update);
380
+ return this.adapter.updateOne(this._translateFilter(filter), this._translatePatchKeys(update));
272
381
  }
273
382
  /**
274
383
  * Deletes a single record by primary key value.
@@ -288,35 +397,145 @@ var AtscriptDbTable = class {
288
397
  filter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
289
398
  }
290
399
  }
291
- return this.adapter.deleteOne(filter);
400
+ return this.adapter.deleteOne(this._translateFilter(filter));
292
401
  }
293
402
  /**
294
403
  * Finds a single record matching the query.
295
404
  */ async findOne(query) {
296
- return this.adapter.findOne(query);
405
+ this._flatten();
406
+ const translatedQuery = this._translateQuery(query);
407
+ const result = await this.adapter.findOne(translatedQuery);
408
+ return result ? this._reconstructFromRead(result) : null;
297
409
  }
298
410
  /**
299
411
  * Finds all records matching the query.
300
412
  */ async findMany(query) {
301
- return this.adapter.findMany(query);
413
+ this._flatten();
414
+ const translatedQuery = this._translateQuery(query);
415
+ const results = await this.adapter.findMany(translatedQuery);
416
+ return results.map((row) => this._reconstructFromRead(row));
302
417
  }
303
418
  /**
304
419
  * Counts records matching the query.
305
420
  */ async count(query) {
421
+ this._flatten();
306
422
  query ?? (query = {
307
423
  filter: {},
308
424
  controls: {}
309
425
  });
310
- return this.adapter.count(query);
426
+ return this.adapter.count(this._translateQuery(query));
427
+ }
428
+ /**
429
+ * Finds records and total count in a single logical call.
430
+ * Adapters may optimize into a single query (e.g., MongoDB `$facet`).
431
+ */ async findManyWithCount(query) {
432
+ this._flatten();
433
+ const translated = this._translateQuery(query);
434
+ const result = await this.adapter.findManyWithCount(translated);
435
+ return {
436
+ data: result.data.map((row) => this._reconstructFromRead(row)),
437
+ count: result.count
438
+ };
439
+ }
440
+ /** Whether the underlying adapter supports text search. */ isSearchable() {
441
+ return this.adapter.isSearchable();
442
+ }
443
+ /** Returns available search indexes from the adapter. */ getSearchIndexes() {
444
+ return this.adapter.getSearchIndexes();
445
+ }
446
+ /**
447
+ * Full-text search with query translation and result reconstruction.
448
+ *
449
+ * @param text - Search text.
450
+ * @param query - Filter, sort, limit, etc.
451
+ * @param indexName - Optional search index to target.
452
+ */ async search(text, query, indexName) {
453
+ this._flatten();
454
+ const translated = this._translateQuery(query);
455
+ const results = await this.adapter.search(text, translated, indexName);
456
+ return results.map((row) => this._reconstructFromRead(row));
457
+ }
458
+ /**
459
+ * Full-text search with count for paginated search results.
460
+ *
461
+ * @param text - Search text.
462
+ * @param query - Filter, sort, limit, etc.
463
+ * @param indexName - Optional search index to target.
464
+ */ async searchWithCount(text, query, indexName) {
465
+ this._flatten();
466
+ const translated = this._translateQuery(query);
467
+ const result = await this.adapter.searchWithCount(text, translated, indexName);
468
+ return {
469
+ data: result.data.map((row) => this._reconstructFromRead(row)),
470
+ count: result.count
471
+ };
472
+ }
473
+ /**
474
+ * Finds a single record by primary key or unique property.
475
+ *
476
+ * 1. Tries primary key lookup (single or composite).
477
+ * 2. Falls back to unique properties if PK validation fails.
478
+ *
479
+ * @param id - Primary key value (scalar for single PK, object for composite).
480
+ * @param controls - Optional query controls ($select, etc.).
481
+ */ async findById(id, controls) {
482
+ this._flatten();
483
+ const pkFields = this.primaryKeys;
484
+ if (pkFields.length === 0) throw new Error("No primary key defined — cannot find by ID");
485
+ const filter = {};
486
+ let pkValid = true;
487
+ if (pkFields.length === 1) {
488
+ const field = pkFields[0];
489
+ const fieldType = this.flatMap.get(field);
490
+ try {
491
+ filter[field] = fieldType ? this.adapter.prepareId(id, fieldType) : id;
492
+ } catch {
493
+ pkValid = false;
494
+ }
495
+ } else if (typeof id !== "object" || id === null) pkValid = false;
496
+ else {
497
+ const idObj = id;
498
+ for (const field of pkFields) {
499
+ const fieldType = this.flatMap.get(field);
500
+ try {
501
+ filter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
502
+ } catch {
503
+ pkValid = false;
504
+ break;
505
+ }
506
+ }
507
+ }
508
+ if (pkValid) return await this.findOne({
509
+ filter,
510
+ controls: controls || {}
511
+ });
512
+ if (this.uniqueProps.size > 0) {
513
+ const orFilters = [];
514
+ for (const prop of this.uniqueProps) {
515
+ const fieldType = this.flatMap.get(prop);
516
+ try {
517
+ const prepared = fieldType ? this.adapter.prepareId(id, fieldType) : id;
518
+ orFilters.push({ [prop]: prepared });
519
+ } catch {}
520
+ }
521
+ return await this.findOne({
522
+ filter: { $or: orFilters },
523
+ controls: controls || {}
524
+ });
525
+ }
526
+ return null;
311
527
  }
312
528
  async updateMany(filter, data) {
313
- return this.adapter.updateMany(filter, data);
529
+ this._flatten();
530
+ return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
314
531
  }
315
532
  async replaceMany(filter, data) {
316
- return this.adapter.replaceMany(filter, data);
533
+ this._flatten();
534
+ return this.adapter.replaceMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
317
535
  }
318
536
  async deleteMany(filter) {
319
- return this.adapter.deleteMany(filter);
537
+ this._flatten();
538
+ return this.adapter.deleteMany(this._translateFilter(filter));
320
539
  }
321
540
  /**
322
541
  * Synchronizes indexes between Atscript definitions and the database.
@@ -342,8 +561,12 @@ var AtscriptDbTable = class {
342
561
  this.adapter.onFieldScanned?.(path, type, metadata);
343
562
  }
344
563
  });
564
+ if (!this._nestedObjects) this._classifyFields();
345
565
  this._finalizeIndexes();
346
566
  this.adapter.onAfterFlatten?.();
567
+ if (this._nestedObjects && this._flatMap) {
568
+ for (const path of this._flatMap.keys()) if (path && !this._ignoredFields.has(path)) this._allPhysicalFields.push(path);
569
+ } else for (const physical of this._pathToPhysical.values()) this._allPhysicalFields.push(physical);
347
570
  }
348
571
  /**
349
572
  * Scans `@db.*` and `@meta.id` annotations on a field during flattening.
@@ -365,7 +588,7 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
365
588
  for (const index of metadata.get("db.index.plain") || []) {
366
589
  const name = index === true ? fieldName : index?.name || fieldName;
367
590
  const sort = (index === true ? undefined : index?.sort) || "asc";
368
- this._addIndexField("plain", name, fieldName, sort);
591
+ this._addIndexField("plain", name, fieldName, { sort });
369
592
  }
370
593
  for (const index of metadata.get("db.index.unique") || []) {
371
594
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
@@ -373,16 +596,23 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
373
596
  }
374
597
  for (const index of metadata.get("db.index.fulltext") || []) {
375
598
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
376
- this._addIndexField("fulltext", name, fieldName);
599
+ const weight = index !== true && typeof index === "object" ? index?.weight : undefined;
600
+ this._addIndexField("fulltext", name, fieldName, { weight });
601
+ }
602
+ if (metadata.has("db.json")) {
603
+ this._jsonFields.add(fieldName);
604
+ const hasIndex = metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext");
605
+ if (hasIndex) this.logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
377
606
  }
378
607
  }
379
- _addIndexField(type, name, field, sort = "asc") {
608
+ _addIndexField(type, name, field, opts) {
380
609
  const key = indexKey(type, name);
381
610
  const index = this._indexes.get(key);
382
611
  const indexField = {
383
612
  name: field,
384
- sort
613
+ sort: opts?.sort ?? "asc"
385
614
  };
615
+ if (opts?.weight !== undefined) indexField.weight = opts.weight;
386
616
  if (index) index.fields.push(indexField);
387
617
  else this._indexes.set(key, {
388
618
  key,
@@ -391,7 +621,57 @@ else this._indexes.set(key, {
391
621
  fields: [indexField]
392
622
  });
393
623
  }
624
+ /**
625
+ * Classifies each field as column, flattened, json, or parent-object.
626
+ * Builds the bidirectional _pathToPhysical / _physicalToPath maps.
627
+ * Only called when the adapter does NOT support nested objects natively.
628
+ */ _classifyFields() {
629
+ for (const [path, type] of this._flatMap.entries()) {
630
+ if (!path) continue;
631
+ const designType = resolveDesignType(type);
632
+ const isJson = this._jsonFields.has(path);
633
+ const isArray = designType === "array";
634
+ const isObject = designType === "object";
635
+ if (isArray) this._jsonFields.add(path);
636
+ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedParents.add(path);
637
+ }
638
+ for (const ignoredField of this._ignoredFields) if (this._flattenedParents.has(ignoredField)) {
639
+ const prefix = `${ignoredField}.`;
640
+ for (const path of this._flatMap.keys()) if (path.startsWith(prefix)) this._ignoredFields.add(path);
641
+ }
642
+ for (const parentPath of this._flattenedParents) if (this._columnMap.has(parentPath)) throw new Error(`@db.column cannot rename a flattened object field "${parentPath}" — ` + `apply @db.column to individual nested fields, or use @db.json to store as a single column`);
643
+ for (const [path] of this._flatMap.entries()) {
644
+ if (!path) continue;
645
+ if (this._flattenedParents.has(path)) continue;
646
+ if (this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
647
+ const isFlattened = this._findAncestorInSet(path, this._flattenedParents) !== undefined;
648
+ const physicalName = this._columnMap.get(path) ?? (isFlattened ? path.replace(/\./g, "__") : path);
649
+ this._pathToPhysical.set(path, physicalName);
650
+ this._physicalToPath.set(physicalName, path);
651
+ const fieldType = this._flatMap?.get(path);
652
+ if (fieldType && resolveDesignType(fieldType) === "boolean") this._booleanFields.add(physicalName);
653
+ }
654
+ for (const parentPath of this._flattenedParents) {
655
+ const prefix = `${parentPath}.`;
656
+ const leaves = [];
657
+ for (const [path, physical] of this._pathToPhysical) if (path.startsWith(prefix)) leaves.push(physical);
658
+ if (leaves.length > 0) this._selectExpansion.set(parentPath, leaves);
659
+ }
660
+ this._requiresMappings = this._flattenedParents.size > 0 || this._jsonFields.size > 0;
661
+ }
662
+ /**
663
+ * Finds the nearest ancestor of `path` that belongs to `set`.
664
+ * Used to locate flattened parents and @db.json ancestors.
665
+ */ _findAncestorInSet(path, set) {
666
+ let pos = path.length;
667
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
668
+ const ancestor = path.slice(0, pos);
669
+ if (set.has(ancestor)) return ancestor;
670
+ }
671
+ return undefined;
672
+ }
394
673
  _finalizeIndexes() {
674
+ for (const index of this._indexes.values()) for (const field of index.fields) field.name = this._pathToPhysical.get(field.name) ?? this._columnMap.get(field.name) ?? field.name;
395
675
  for (const index of this._indexes.values()) if (index.type === "unique" && index.fields.length === 1) this._uniqueProps.add(index.fields[0].name);
396
676
  }
397
677
  /**
@@ -399,13 +679,26 @@ else this._indexes.set(key, {
399
679
  * Called before validation so that defaults satisfy required field constraints.
400
680
  */ _applyDefaults(data) {
401
681
  for (const [field, def] of this._defaults.entries()) if (data[field] === undefined) {
402
- if (def.kind === "value") data[field] = def.value;
682
+ if (def.kind === "value") {
683
+ const fieldType = this._flatMap?.get(field);
684
+ const designType = fieldType?.type.kind === "" && fieldType.type.designType;
685
+ data[field] = designType === "string" ? def.value : JSON.parse(def.value);
686
+ } else if (def.kind === "fn") switch (def.fn) {
687
+ case "now": {
688
+ data[field] = Date.now();
689
+ break;
690
+ }
691
+ case "uuid": {
692
+ data[field] = crypto.randomUUID();
693
+ break;
694
+ }
695
+ }
403
696
  }
404
697
  return data;
405
698
  }
406
699
  /**
407
700
  * Prepares a payload for writing to the database:
408
- * prepares IDs, strips ignored fields, maps column names.
701
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
409
702
  * Defaults should be applied before this via `_applyDefaults`.
410
703
  */ _prepareForWrite(payload) {
411
704
  const data = { ...payload };
@@ -413,12 +706,224 @@ else this._indexes.set(key, {
413
706
  const fieldType = this._flatMap?.get(pk);
414
707
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
415
708
  }
416
- for (const field of this._ignoredFields) delete data[field];
417
- for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
418
- data[physical] = data[logical];
419
- delete data[logical];
709
+ for (const field of this._ignoredFields) if (!field.includes(".")) delete data[field];
710
+ if (!this._requiresMappings || this._nestedObjects) {
711
+ for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
712
+ data[physical] = data[logical];
713
+ delete data[logical];
714
+ }
715
+ return data;
420
716
  }
421
- return data;
717
+ return this._flattenPayload(data);
718
+ }
719
+ /**
720
+ * Flattens nested object fields into __-separated keys and
721
+ * JSON-stringifies @db.json / array fields.
722
+ * Uses _pathToPhysical for final key names.
723
+ */ _flattenPayload(data) {
724
+ const result = {};
725
+ for (const key of Object.keys(data)) this._writeFlattenedField(key, data[key], result);
726
+ return result;
727
+ }
728
+ /**
729
+ * Classifies and writes a single field to the result object.
730
+ * Recurses into nested objects that should be flattened.
731
+ */ _writeFlattenedField(path, value, result) {
732
+ if (this._ignoredFields.has(path)) return;
733
+ if (this._flattenedParents.has(path)) {
734
+ if (value === null || value === undefined) this._setFlattenedChildrenNull(path, result);
735
+ else if (typeof value === "object" && !Array.isArray(value)) {
736
+ const obj = value;
737
+ for (const key of Object.keys(obj)) this._writeFlattenedField(`${path}.${key}`, obj[key], result);
738
+ }
739
+ } else if (this._jsonFields.has(path)) {
740
+ const physical = this._pathToPhysical.get(path) ?? path.replace(/\./g, "__");
741
+ result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
742
+ } else {
743
+ const physical = this._pathToPhysical.get(path) ?? path.replace(/\./g, "__");
744
+ result[physical] = value;
745
+ }
746
+ }
747
+ /**
748
+ * When a parent object is null/undefined, set all its flattened children to null.
749
+ */ _setFlattenedChildrenNull(parentPath, result) {
750
+ const prefix = `${parentPath}.`;
751
+ for (const [path, physical] of this._pathToPhysical.entries()) if (path.startsWith(prefix)) result[physical] = null;
752
+ }
753
+ /**
754
+ * Reconstructs nested objects from flat __-separated column values.
755
+ * JSON fields are parsed from strings back to objects/arrays.
756
+ */ _reconstructFromRead(row) {
757
+ if (!this._requiresMappings || this._nestedObjects) return this._coerceBooleans(row);
758
+ const result = {};
759
+ const rowKeys = Object.keys(row);
760
+ for (const physical of rowKeys) {
761
+ const value = this._booleanFields.has(physical) ? toBool(row[physical]) : row[physical];
762
+ const logicalPath = this._physicalToPath.get(physical);
763
+ if (!logicalPath) {
764
+ result[physical] = value;
765
+ continue;
766
+ }
767
+ if (this._jsonFields.has(logicalPath)) {
768
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
769
+ this._setNestedValue(result, logicalPath, parsed);
770
+ } else if (logicalPath.includes(".")) this._setNestedValue(result, logicalPath, value);
771
+ else result[logicalPath] = value;
772
+ }
773
+ for (const parentPath of this._flattenedParents) this._reconstructNullParent(result, parentPath);
774
+ return result;
775
+ }
776
+ /**
777
+ * Coerces boolean fields from storage representation (0/1) to JS booleans.
778
+ * Used on the fast-path when no column mapping is needed.
779
+ */ _coerceBooleans(row) {
780
+ if (this._booleanFields.size === 0) return row;
781
+ for (const field of this._booleanFields) if (field in row) row[field] = toBool(row[field]);
782
+ return row;
783
+ }
784
+ /**
785
+ * Sets a value at a dot-notation path, creating intermediate objects as needed.
786
+ */ _setNestedValue(obj, dotPath, value) {
787
+ const parts = dotPath.split(".");
788
+ let current = obj;
789
+ for (let i = 0; i < parts.length - 1; i++) {
790
+ const part = parts[i];
791
+ if (current[part] === undefined || current[part] === null) current[part] = {};
792
+ current = current[part];
793
+ }
794
+ current[parts[parts.length - 1]] = value;
795
+ }
796
+ /**
797
+ * If all children of a flattened parent are null, collapse the parent to null.
798
+ */ _reconstructNullParent(obj, parentPath) {
799
+ const parts = parentPath.split(".");
800
+ let current = obj;
801
+ for (let i = 0; i < parts.length - 1; i++) {
802
+ if (current[parts[i]] === undefined) return;
803
+ current = current[parts[i]];
804
+ }
805
+ const lastPart = parts[parts.length - 1];
806
+ const parentObj = current[lastPart];
807
+ if (typeof parentObj !== "object" || parentObj === null) return;
808
+ let allNull = true;
809
+ const parentKeys = Object.keys(parentObj);
810
+ for (const k of parentKeys) {
811
+ const v = parentObj[k];
812
+ if (v !== null && v !== undefined) {
813
+ allNull = false;
814
+ break;
815
+ }
816
+ }
817
+ if (allNull) {
818
+ const parentType = this._flatMap?.get(parentPath);
819
+ current[lastPart] = parentType?.optional ? null : {};
820
+ }
821
+ }
822
+ /**
823
+ * Translates a Uniquery's filter, sort, and projection from logical
824
+ * dot-notation paths to physical column names.
825
+ * Always wraps `$select` in {@link UniquSelect}.
826
+ */ _translateQuery(query) {
827
+ if (!this._requiresMappings || this._nestedObjects) {
828
+ const controls = query.controls;
829
+ return {
830
+ filter: query.filter,
831
+ controls: {
832
+ ...controls,
833
+ $select: controls?.$select ? new UniquSelect(controls.$select, this._allPhysicalFields) : undefined
834
+ }
835
+ };
836
+ }
837
+ return {
838
+ filter: this._translateFilter(query.filter),
839
+ controls: query.controls ? this._translateControls(query.controls) : {}
840
+ };
841
+ }
842
+ /**
843
+ * Recursively translates field names in a filter expression.
844
+ */ _translateFilter(filter) {
845
+ if (!filter || typeof filter !== "object") return filter;
846
+ if (!this._requiresMappings) return filter;
847
+ const result = {};
848
+ const filterKeys = Object.keys(filter);
849
+ for (const key of filterKeys) {
850
+ const value = filter[key];
851
+ if (key === "$and" || key === "$or") result[key] = value.map((f) => this._translateFilter(f));
852
+ else if (key === "$not") result[key] = this._translateFilter(value);
853
+ else if (key.startsWith("$")) result[key] = value;
854
+ else {
855
+ const physical = this._pathToPhysical.get(key) ?? key;
856
+ result[physical] = value;
857
+ }
858
+ }
859
+ return result;
860
+ }
861
+ /**
862
+ * Translates field names in sort and projection controls.
863
+ * Wraps `$select` in {@link UniquSelect} after path translation.
864
+ */ _translateControls(controls) {
865
+ if (!controls) return {};
866
+ const result = {
867
+ ...controls,
868
+ $select: undefined
869
+ };
870
+ if (controls.$sort) {
871
+ const translated = {};
872
+ const sortObj = controls.$sort;
873
+ const sortKeys = Object.keys(sortObj);
874
+ for (const key of sortKeys) {
875
+ if (this._flattenedParents.has(key)) continue;
876
+ const physical = this._pathToPhysical.get(key) ?? key;
877
+ translated[physical] = sortObj[key];
878
+ }
879
+ result.$sort = translated;
880
+ }
881
+ if (controls.$select) {
882
+ let translatedRaw;
883
+ if (Array.isArray(controls.$select)) {
884
+ const expanded = [];
885
+ for (const key of controls.$select) {
886
+ const expansion = this._selectExpansion.get(key);
887
+ if (expansion) expanded.push(...expansion);
888
+ else expanded.push(this._pathToPhysical.get(key) ?? key);
889
+ }
890
+ translatedRaw = expanded;
891
+ } else {
892
+ const translated = {};
893
+ const selectObj = controls.$select;
894
+ const selectKeys = Object.keys(selectObj);
895
+ for (const key of selectKeys) {
896
+ const val = selectObj[key];
897
+ const expansion = this._selectExpansion.get(key);
898
+ if (expansion) for (const leaf of expansion) translated[leaf] = val;
899
+ else {
900
+ const physical = this._pathToPhysical.get(key) ?? key;
901
+ translated[physical] = val;
902
+ }
903
+ }
904
+ translatedRaw = translated;
905
+ }
906
+ result.$select = new UniquSelect(translatedRaw, this._allPhysicalFields);
907
+ }
908
+ return result;
909
+ }
910
+ /**
911
+ * Translates dot-notation keys in a decomposed patch to physical column names.
912
+ */ _translatePatchKeys(update) {
913
+ if (!this._requiresMappings || this._nestedObjects) return update;
914
+ const result = {};
915
+ const updateKeys = Object.keys(update);
916
+ for (const key of updateKeys) {
917
+ const value = update[key];
918
+ const operatorMatch = key.match(/^(.+?)(\.__\$.+)$/);
919
+ const basePath = operatorMatch ? operatorMatch[1] : key;
920
+ const suffix = operatorMatch ? operatorMatch[2] : "";
921
+ const physical = this._pathToPhysical.get(basePath) ?? basePath;
922
+ const finalKey = physical + suffix;
923
+ if (this._jsonFields.has(basePath) && typeof value === "object" && value !== null && !suffix) result[finalKey] = JSON.stringify(value);
924
+ else result[finalKey] = value;
925
+ }
926
+ return result;
422
927
  }
423
928
  /**
424
929
  * Extracts primary key field(s) from a payload to build a filter.
@@ -438,21 +943,26 @@ else this._indexes.set(key, {
438
943
  */ _buildValidator(purpose) {
439
944
  const plugins = this.adapter.getValidatorPlugins();
440
945
  switch (purpose) {
441
- case "insert": return this.createValidator({
442
- plugins,
443
- replace: (type, path) => {
444
- if (this._primaryKeys.includes(path)) return {
445
- ...type,
446
- optional: true
447
- };
448
- return type;
449
- }
450
- });
451
- case "update": return this.createValidator({ plugins });
452
- case "patch": return this.createValidator({
453
- plugins,
454
- partial: true
455
- });
946
+ case "insert": {
947
+ if (this.adapter.buildInsertValidator) return this.adapter.buildInsertValidator(this);
948
+ return this.createValidator({
949
+ plugins,
950
+ replace: (type, path) => {
951
+ if (this._primaryKeys.includes(path) || this._defaults.has(path)) return {
952
+ ...type,
953
+ optional: true
954
+ };
955
+ return type;
956
+ }
957
+ });
958
+ }
959
+ case "patch": {
960
+ if (this.adapter.buildPatchValidator) return this.adapter.buildPatchValidator(this);
961
+ return this.createValidator({
962
+ plugins,
963
+ partial: true
964
+ });
965
+ }
456
966
  default: return this.createValidator({ plugins });
457
967
  }
458
968
  }
@@ -470,6 +980,15 @@ else this._indexes.set(key, {
470
980
  _define_property$1(this, "_defaults", void 0);
471
981
  _define_property$1(this, "_ignoredFields", void 0);
472
982
  _define_property$1(this, "_uniqueProps", void 0);
983
+ /** Logical dot-path → physical column name. */ _define_property$1(this, "_pathToPhysical", void 0);
984
+ /** Physical column name → logical dot-path (inverse). */ _define_property$1(this, "_physicalToPath", void 0);
985
+ /** Object paths being flattened into __-separated columns (no column themselves). */ _define_property$1(this, "_flattenedParents", void 0);
986
+ /** Fields stored as JSON (@db.json + array fields). */ _define_property$1(this, "_jsonFields", void 0);
987
+ /** Intermediate paths → their leaf physical column names (for $select expansion in relational DBs). */ _define_property$1(this, "_selectExpansion", void 0);
988
+ /** Physical column names of boolean fields (for storage coercion on read). */ _define_property$1(this, "_booleanFields", void 0);
989
+ /** Fast-path flag: skip all mapping when no nested/json fields exist. */ _define_property$1(this, "_requiresMappings", void 0);
990
+ /** All non-ignored physical field names (for UniquSelect exclusion inversion). */ _define_property$1(this, "_allPhysicalFields", void 0);
991
+ /** Cached result of adapter.supportsNestedObjects(). */ _define_property$1(this, "_nestedObjects", void 0);
473
992
  _define_property$1(this, "validators", void 0);
474
993
  this._type = _type;
475
994
  this.adapter = adapter;
@@ -480,6 +999,14 @@ else this._indexes.set(key, {
480
999
  this._defaults = new Map();
481
1000
  this._ignoredFields = new Set();
482
1001
  this._uniqueProps = new Set();
1002
+ this._pathToPhysical = new Map();
1003
+ this._physicalToPath = new Map();
1004
+ this._flattenedParents = new Set();
1005
+ this._jsonFields = new Set();
1006
+ this._selectExpansion = new Map();
1007
+ this._booleanFields = new Set();
1008
+ this._requiresMappings = false;
1009
+ this._allPhysicalFields = [];
483
1010
  this.validators = new Map();
484
1011
  if (!(0, __atscript_typescript_utils.isAnnotatedType)(_type)) throw new Error("Atscript Annotated Type expected");
485
1012
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -489,6 +1016,7 @@ else this._indexes.set(key, {
489
1016
  this.tableName = adapterName || dbTable || fallbackName;
490
1017
  if (!this.tableName) throw new Error("@db.table annotation or adapter-specific table name expected");
491
1018
  this.schema = _type.metadata.get("db.schema");
1019
+ this._nestedObjects = adapter.supportsNestedObjects();
492
1020
  adapter.registerTable(this);
493
1021
  }
494
1022
  };
@@ -539,6 +1067,14 @@ var BaseDbAdapter = class {
539
1067
  return false;
540
1068
  }
541
1069
  /**
1070
+ * Whether this adapter handles nested objects natively.
1071
+ * When `true`, the generic layer skips flattening and
1072
+ * passes nested objects as-is to the adapter.
1073
+ * MongoDB returns `true`; relational adapters return `false` (default).
1074
+ */ supportsNestedObjects() {
1075
+ return false;
1076
+ }
1077
+ /**
542
1078
  * Applies a patch payload using native database operations.
543
1079
  * Only called when {@link supportsNativePatch} returns `true`.
544
1080
  *
@@ -586,6 +1122,46 @@ var BaseDbAdapter = class {
586
1122
  }
587
1123
  for (const name of existingNames) if (!desiredNames.has(name)) await opts.dropIndex(name);
588
1124
  }
1125
+ /**
1126
+ * Returns available search indexes for this adapter.
1127
+ * UI uses this to show index picker. Override in adapters that support search.
1128
+ */ getSearchIndexes() {
1129
+ return [];
1130
+ }
1131
+ /**
1132
+ * Whether this adapter supports text search.
1133
+ * Default: `true` when {@link getSearchIndexes} returns any entries.
1134
+ */ isSearchable() {
1135
+ return this.getSearchIndexes().length > 0;
1136
+ }
1137
+ /**
1138
+ * Full-text search. Override in adapters that support search.
1139
+ *
1140
+ * @param text - Search text.
1141
+ * @param query - Filter, sort, limit, etc.
1142
+ * @param indexName - Optional search index to target.
1143
+ */ async search(text, query, indexName) {
1144
+ throw new Error("Search not supported by this adapter");
1145
+ }
1146
+ /**
1147
+ * Full-text search with count (for paginated search results).
1148
+ *
1149
+ * @param text - Search text.
1150
+ * @param query - Filter, sort, limit, etc.
1151
+ * @param indexName - Optional search index to target.
1152
+ */ async searchWithCount(text, query, indexName) {
1153
+ throw new Error("Search not supported by this adapter");
1154
+ }
1155
+ /**
1156
+ * Fetches records and total count in one call.
1157
+ * Default: two parallel calls. Adapters may override for single-query optimization.
1158
+ */ async findManyWithCount(query) {
1159
+ const [data, count] = await Promise.all([this.findMany(query), this.count(query)]);
1160
+ return {
1161
+ data,
1162
+ count
1163
+ };
1164
+ }
589
1165
  constructor() {
590
1166
  _define_property(this, "_table", void 0);
591
1167
  }
@@ -595,6 +1171,7 @@ var BaseDbAdapter = class {
595
1171
  exports.AtscriptDbTable = AtscriptDbTable
596
1172
  exports.BaseDbAdapter = BaseDbAdapter
597
1173
  exports.NoopLogger = NoopLogger
1174
+ exports.UniquSelect = UniquSelect
598
1175
  exports.decomposePatch = decomposePatch
599
1176
  exports.getKeyProps = getKeyProps
600
1177
  Object.defineProperty(exports, 'isPrimitive', {