@atscript/utils-db 0.1.31 → 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.mjs CHANGED
@@ -77,6 +77,78 @@ else update[key] = value;
77
77
  }
78
78
  }
79
79
 
80
+ //#endregion
81
+ //#region packages/utils-db/src/uniqu-select.ts
82
+ function _define_property$2(obj, key, value) {
83
+ if (key in obj) Object.defineProperty(obj, key, {
84
+ value,
85
+ enumerable: true,
86
+ configurable: true,
87
+ writable: true
88
+ });
89
+ else obj[key] = value;
90
+ return obj;
91
+ }
92
+ var UniquSelect = class {
93
+ /**
94
+ * Resolved inclusion array of field names.
95
+ * For exclusion form, inverts using `allFields` from constructor.
96
+ */ get asArray() {
97
+ if (this._arrayResolved) return this._array;
98
+ this._arrayResolved = true;
99
+ if (Array.isArray(this._raw)) {
100
+ this._array = this._raw;
101
+ return this._array;
102
+ }
103
+ const raw = this._raw;
104
+ const entries = Object.entries(raw);
105
+ if (entries.length === 0) return undefined;
106
+ if (entries[0][1] === 1) {
107
+ const result = [];
108
+ for (const entry of entries) if (entry[1] === 1) result.push(entry[0]);
109
+ this._array = result;
110
+ } else {
111
+ if (!this._allFields) return undefined;
112
+ const excluded = new Set();
113
+ for (const entry of entries) if (entry[1] === 0) excluded.add(entry[0]);
114
+ const result = [];
115
+ for (const field of this._allFields) if (!excluded.has(field)) result.push(field);
116
+ this._array = result;
117
+ }
118
+ return this._array;
119
+ }
120
+ /**
121
+ * Record projection preserving original semantics.
122
+ * Returns original object as-is if raw was object.
123
+ * Converts `string[]` to `{field: 1}` inclusion object.
124
+ */ get asProjection() {
125
+ if (this._projectionResolved) return this._projection;
126
+ this._projectionResolved = true;
127
+ if (!Array.isArray(this._raw)) {
128
+ const raw = this._raw;
129
+ if (Object.keys(raw).length === 0) return undefined;
130
+ this._projection = raw;
131
+ return this._projection;
132
+ }
133
+ const arr = this._raw;
134
+ if (arr.length === 0) return undefined;
135
+ const result = {};
136
+ for (const item of arr) result[item] = 1;
137
+ this._projection = result;
138
+ return this._projection;
139
+ }
140
+ constructor(raw, allFields) {
141
+ _define_property$2(this, "_raw", void 0);
142
+ _define_property$2(this, "_allFields", void 0);
143
+ _define_property$2(this, "_arrayResolved", false);
144
+ _define_property$2(this, "_array", void 0);
145
+ _define_property$2(this, "_projectionResolved", false);
146
+ _define_property$2(this, "_projection", void 0);
147
+ this._raw = raw;
148
+ this._allFields = allFields;
149
+ }
150
+ };
151
+
80
152
  //#endregion
81
153
  //#region packages/utils-db/src/db-table.ts
82
154
  function _define_property$1(obj, key, value) {
@@ -96,11 +168,18 @@ function resolveDesignType(fieldType) {
96
168
  if (fieldType.type.kind === "array") return "array";
97
169
  return "string";
98
170
  }
171
+ /** Coerces a storage value (0/1/null) back to a JS boolean. */ function toBool(value) {
172
+ if (value === null || value === undefined) return value;
173
+ return !!value;
174
+ }
99
175
  function indexKey(type, name) {
100
176
  const cleanName = name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 127 - INDEX_PREFIX.length - type.length - 2);
101
177
  return `${INDEX_PREFIX}${type}__${cleanName}`;
102
178
  }
103
179
  var AtscriptDbTable = class {
180
+ /** Returns the underlying adapter with its concrete type preserved. */ getAdapter() {
181
+ return this.adapter;
182
+ }
104
183
  /** The raw annotated type. */ get type() {
105
184
  return this._type;
106
185
  }
@@ -116,6 +195,29 @@ var AtscriptDbTable = class {
116
195
  this._flatten();
117
196
  return this._primaryKeys;
118
197
  }
198
+ /**
199
+ * Registers an additional primary key field.
200
+ * Useful for adapters (e.g., MongoDB) where `_id` is always the primary key
201
+ * even without an explicit `@meta.id` annotation.
202
+ *
203
+ * Typically called from {@link BaseDbAdapter.onFieldScanned}.
204
+ */ addPrimaryKey(field) {
205
+ if (!this._primaryKeys.includes(field)) this._primaryKeys.push(field);
206
+ }
207
+ /**
208
+ * Removes a field from the primary key list.
209
+ * Useful for adapters (e.g., MongoDB) where `@meta.id` fields should be
210
+ * unique indexes rather than part of the primary key.
211
+ */ removePrimaryKey(field) {
212
+ const idx = this._primaryKeys.indexOf(field);
213
+ if (idx >= 0) this._primaryKeys.splice(idx, 1);
214
+ }
215
+ /**
216
+ * Registers a field as having a unique constraint.
217
+ * Used by adapters to ensure `findById` falls back to this field.
218
+ */ addUniqueField(field) {
219
+ this._uniqueProps.add(field);
220
+ }
119
221
  /** Logical → physical column name mapping from `@db.column`. */ get columnMap() {
120
222
  this._flatten();
121
223
  return this._columnMap;
@@ -155,13 +257,13 @@ var AtscriptDbTable = class {
155
257
  this._flatten();
156
258
  if (!this._fieldDescriptors) {
157
259
  this._fieldDescriptors = [];
158
- const skipFlattening = this.adapter.supportsNestedObjects();
260
+ const skipFlattening = this._nestedObjects;
159
261
  for (const [path, type] of this._flatMap.entries()) {
160
262
  if (!path) continue;
161
263
  if (!skipFlattening && this._flattenedParents.has(path)) continue;
162
- if (!skipFlattening && this._findJsonParent(path) !== undefined) continue;
264
+ if (!skipFlattening && this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
163
265
  const isJson = this._jsonFields.has(path);
164
- const isFlattened = !skipFlattening && this._findFlattenedParent(path) !== undefined;
266
+ const isFlattened = !skipFlattening && this._findAncestorInSet(path, this._flattenedParents) !== undefined;
165
267
  const designType = isJson ? "json" : resolveDesignType(type);
166
268
  let storage;
167
269
  if (skipFlattening) storage = "column";
@@ -182,33 +284,11 @@ else storage = "column";
182
284
  flattenedFrom: isFlattened ? path : undefined
183
285
  });
184
286
  }
287
+ Object.freeze(this._fieldDescriptors);
185
288
  }
186
289
  return this._fieldDescriptors;
187
290
  }
188
291
  /**
189
- * Resolves `$select` from {@link UniqueryControls} to a list of field names.
190
- * - `undefined` → `undefined` (all fields)
191
- * - `string[]` → pass through
192
- * - `Record<K, 1>` → extract included keys
193
- * - `Record<K, 0>` → invert using known field names
194
- */ resolveProjection(select) {
195
- if (!select) return undefined;
196
- if (Array.isArray(select)) return select.length > 0 ? select : undefined;
197
- const selectObj = select;
198
- const keys = Object.keys(selectObj);
199
- if (keys.length === 0) return undefined;
200
- if (selectObj[keys[0]] === 1) {
201
- const result$1 = [];
202
- for (const k of keys) if (selectObj[k] === 1) result$1.push(k);
203
- return result$1;
204
- }
205
- const excluded = new Set();
206
- for (const k of keys) if (selectObj[k] === 0) excluded.add(k);
207
- const result = [];
208
- for (const f of this.fieldDescriptors) if (!f.ignored && !excluded.has(f.path)) result.push(f.path);
209
- return result;
210
- }
211
- /**
212
292
  * Creates a new validator with custom options.
213
293
  * Adapter plugins are NOT automatically included — use {@link getValidator}
214
294
  * for the standard validator with adapter plugins.
@@ -321,6 +401,106 @@ else storage = "column";
321
401
  });
322
402
  return this.adapter.count(this._translateQuery(query));
323
403
  }
404
+ /**
405
+ * Finds records and total count in a single logical call.
406
+ * Adapters may optimize into a single query (e.g., MongoDB `$facet`).
407
+ */ async findManyWithCount(query) {
408
+ this._flatten();
409
+ const translated = this._translateQuery(query);
410
+ const result = await this.adapter.findManyWithCount(translated);
411
+ return {
412
+ data: result.data.map((row) => this._reconstructFromRead(row)),
413
+ count: result.count
414
+ };
415
+ }
416
+ /** Whether the underlying adapter supports text search. */ isSearchable() {
417
+ return this.adapter.isSearchable();
418
+ }
419
+ /** Returns available search indexes from the adapter. */ getSearchIndexes() {
420
+ return this.adapter.getSearchIndexes();
421
+ }
422
+ /**
423
+ * Full-text search with query translation and result reconstruction.
424
+ *
425
+ * @param text - Search text.
426
+ * @param query - Filter, sort, limit, etc.
427
+ * @param indexName - Optional search index to target.
428
+ */ async search(text, query, indexName) {
429
+ this._flatten();
430
+ const translated = this._translateQuery(query);
431
+ const results = await this.adapter.search(text, translated, indexName);
432
+ return results.map((row) => this._reconstructFromRead(row));
433
+ }
434
+ /**
435
+ * Full-text search with count for paginated search results.
436
+ *
437
+ * @param text - Search text.
438
+ * @param query - Filter, sort, limit, etc.
439
+ * @param indexName - Optional search index to target.
440
+ */ async searchWithCount(text, query, indexName) {
441
+ this._flatten();
442
+ const translated = this._translateQuery(query);
443
+ const result = await this.adapter.searchWithCount(text, translated, indexName);
444
+ return {
445
+ data: result.data.map((row) => this._reconstructFromRead(row)),
446
+ count: result.count
447
+ };
448
+ }
449
+ /**
450
+ * Finds a single record by primary key or unique property.
451
+ *
452
+ * 1. Tries primary key lookup (single or composite).
453
+ * 2. Falls back to unique properties if PK validation fails.
454
+ *
455
+ * @param id - Primary key value (scalar for single PK, object for composite).
456
+ * @param controls - Optional query controls ($select, etc.).
457
+ */ async findById(id, controls) {
458
+ this._flatten();
459
+ const pkFields = this.primaryKeys;
460
+ if (pkFields.length === 0) throw new Error("No primary key defined — cannot find by ID");
461
+ const filter = {};
462
+ let pkValid = true;
463
+ if (pkFields.length === 1) {
464
+ const field = pkFields[0];
465
+ const fieldType = this.flatMap.get(field);
466
+ try {
467
+ filter[field] = fieldType ? this.adapter.prepareId(id, fieldType) : id;
468
+ } catch {
469
+ pkValid = false;
470
+ }
471
+ } else if (typeof id !== "object" || id === null) pkValid = false;
472
+ else {
473
+ const idObj = id;
474
+ for (const field of pkFields) {
475
+ const fieldType = this.flatMap.get(field);
476
+ try {
477
+ filter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
478
+ } catch {
479
+ pkValid = false;
480
+ break;
481
+ }
482
+ }
483
+ }
484
+ if (pkValid) return await this.findOne({
485
+ filter,
486
+ controls: controls || {}
487
+ });
488
+ if (this.uniqueProps.size > 0) {
489
+ const orFilters = [];
490
+ for (const prop of this.uniqueProps) {
491
+ const fieldType = this.flatMap.get(prop);
492
+ try {
493
+ const prepared = fieldType ? this.adapter.prepareId(id, fieldType) : id;
494
+ orFilters.push({ [prop]: prepared });
495
+ } catch {}
496
+ }
497
+ return await this.findOne({
498
+ filter: { $or: orFilters },
499
+ controls: controls || {}
500
+ });
501
+ }
502
+ return null;
503
+ }
324
504
  async updateMany(filter, data) {
325
505
  this._flatten();
326
506
  return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
@@ -357,9 +537,12 @@ else storage = "column";
357
537
  this.adapter.onFieldScanned?.(path, type, metadata);
358
538
  }
359
539
  });
360
- if (!this.adapter.supportsNestedObjects()) this._classifyFields();
540
+ if (!this._nestedObjects) this._classifyFields();
361
541
  this._finalizeIndexes();
362
542
  this.adapter.onAfterFlatten?.();
543
+ if (this._nestedObjects && this._flatMap) {
544
+ for (const path of this._flatMap.keys()) if (path && !this._ignoredFields.has(path)) this._allPhysicalFields.push(path);
545
+ } else for (const physical of this._pathToPhysical.values()) this._allPhysicalFields.push(physical);
363
546
  }
364
547
  /**
365
548
  * Scans `@db.*` and `@meta.id` annotations on a field during flattening.
@@ -381,7 +564,7 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
381
564
  for (const index of metadata.get("db.index.plain") || []) {
382
565
  const name = index === true ? fieldName : index?.name || fieldName;
383
566
  const sort = (index === true ? undefined : index?.sort) || "asc";
384
- this._addIndexField("plain", name, fieldName, sort);
567
+ this._addIndexField("plain", name, fieldName, { sort });
385
568
  }
386
569
  for (const index of metadata.get("db.index.unique") || []) {
387
570
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
@@ -389,7 +572,8 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
389
572
  }
390
573
  for (const index of metadata.get("db.index.fulltext") || []) {
391
574
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
392
- this._addIndexField("fulltext", name, fieldName);
575
+ const weight = index !== true && typeof index === "object" ? index?.weight : undefined;
576
+ this._addIndexField("fulltext", name, fieldName, { weight });
393
577
  }
394
578
  if (metadata.has("db.json")) {
395
579
  this._jsonFields.add(fieldName);
@@ -397,13 +581,14 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
397
581
  if (hasIndex) this.logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
398
582
  }
399
583
  }
400
- _addIndexField(type, name, field, sort = "asc") {
584
+ _addIndexField(type, name, field, opts) {
401
585
  const key = indexKey(type, name);
402
586
  const index = this._indexes.get(key);
403
587
  const indexField = {
404
588
  name: field,
405
- sort
589
+ sort: opts?.sort ?? "asc"
406
590
  };
591
+ if (opts?.weight !== undefined) indexField.weight = opts.weight;
407
592
  if (index) index.fields.push(indexField);
408
593
  else this._indexes.set(key, {
409
594
  key,
@@ -434,11 +619,13 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
434
619
  for (const [path] of this._flatMap.entries()) {
435
620
  if (!path) continue;
436
621
  if (this._flattenedParents.has(path)) continue;
437
- if (this._findJsonParent(path) !== undefined) continue;
438
- const isFlattened = this._findFlattenedParent(path) !== undefined;
622
+ if (this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
623
+ const isFlattened = this._findAncestorInSet(path, this._flattenedParents) !== undefined;
439
624
  const physicalName = this._columnMap.get(path) ?? (isFlattened ? path.replace(/\./g, "__") : path);
440
625
  this._pathToPhysical.set(path, physicalName);
441
626
  this._physicalToPath.set(physicalName, path);
627
+ const fieldType = this._flatMap?.get(path);
628
+ if (fieldType && resolveDesignType(fieldType) === "boolean") this._booleanFields.add(physicalName);
442
629
  }
443
630
  for (const parentPath of this._flattenedParents) {
444
631
  const prefix = `${parentPath}.`;
@@ -449,24 +636,13 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
449
636
  this._requiresMappings = this._flattenedParents.size > 0 || this._jsonFields.size > 0;
450
637
  }
451
638
  /**
452
- * Finds the nearest ancestor that is a flattened parent object.
453
- * Returns the parent path, or undefined if no ancestor is being flattened.
454
- */ _findFlattenedParent(path) {
639
+ * Finds the nearest ancestor of `path` that belongs to `set`.
640
+ * Used to locate flattened parents and @db.json ancestors.
641
+ */ _findAncestorInSet(path, set) {
455
642
  let pos = path.length;
456
643
  while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
457
644
  const ancestor = path.slice(0, pos);
458
- if (this._flattenedParents.has(ancestor)) return ancestor;
459
- }
460
- return undefined;
461
- }
462
- /**
463
- * Finds the nearest ancestor that is a @db.json field.
464
- * Children of JSON fields don't get their own columns.
465
- */ _findJsonParent(path) {
466
- let pos = path.length;
467
- while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
468
- const ancestor = path.slice(0, pos);
469
- if (this._jsonFields.has(ancestor)) return ancestor;
645
+ if (set.has(ancestor)) return ancestor;
470
646
  }
471
647
  return undefined;
472
648
  }
@@ -479,7 +655,20 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
479
655
  * Called before validation so that defaults satisfy required field constraints.
480
656
  */ _applyDefaults(data) {
481
657
  for (const [field, def] of this._defaults.entries()) if (data[field] === undefined) {
482
- if (def.kind === "value") data[field] = def.value;
658
+ if (def.kind === "value") {
659
+ const fieldType = this._flatMap?.get(field);
660
+ const designType = fieldType?.type.kind === "" && fieldType.type.designType;
661
+ data[field] = designType === "string" ? def.value : JSON.parse(def.value);
662
+ } else if (def.kind === "fn") switch (def.fn) {
663
+ case "now": {
664
+ data[field] = Date.now();
665
+ break;
666
+ }
667
+ case "uuid": {
668
+ data[field] = crypto.randomUUID();
669
+ break;
670
+ }
671
+ }
483
672
  }
484
673
  return data;
485
674
  }
@@ -494,7 +683,7 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
494
683
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
495
684
  }
496
685
  for (const field of this._ignoredFields) if (!field.includes(".")) delete data[field];
497
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) {
686
+ if (!this._requiresMappings || this._nestedObjects) {
498
687
  for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
499
688
  data[physical] = data[logical];
500
689
  delete data[logical];
@@ -509,40 +698,26 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
509
698
  * Uses _pathToPhysical for final key names.
510
699
  */ _flattenPayload(data) {
511
700
  const result = {};
512
- const dataKeys = Object.keys(data);
513
- for (const key of dataKeys) {
514
- const value = data[key];
515
- if (this._flattenedParents.has(key)) {
516
- if (value === null || value === undefined) this._setFlattenedChildrenNull(key, result);
517
- else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(key, value, result);
518
- } else if (this._jsonFields.has(key)) {
519
- const physical = this._pathToPhysical.get(key) ?? key;
520
- result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
521
- } else {
522
- const physical = this._pathToPhysical.get(key) ?? key;
523
- result[physical] = value;
524
- }
525
- }
701
+ for (const key of Object.keys(data)) this._writeFlattenedField(key, data[key], result);
526
702
  return result;
527
703
  }
528
704
  /**
529
- * Recursively flattens a nested object, writing physical keys to result.
530
- */ _flattenObject(prefix, obj, result) {
531
- const objKeys = Object.keys(obj);
532
- for (const key of objKeys) {
533
- const value = obj[key];
534
- const dotPath = `${prefix}.${key}`;
535
- if (this._ignoredFields.has(dotPath)) continue;
536
- if (this._flattenedParents.has(dotPath)) {
537
- if (value === null || value === undefined) this._setFlattenedChildrenNull(dotPath, result);
538
- else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(dotPath, value, result);
539
- } else if (this._jsonFields.has(dotPath)) {
540
- const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
541
- result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
542
- } else {
543
- const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
544
- result[physical] = value;
705
+ * Classifies and writes a single field to the result object.
706
+ * Recurses into nested objects that should be flattened.
707
+ */ _writeFlattenedField(path, value, result) {
708
+ if (this._ignoredFields.has(path)) return;
709
+ if (this._flattenedParents.has(path)) {
710
+ if (value === null || value === undefined) this._setFlattenedChildrenNull(path, result);
711
+ else if (typeof value === "object" && !Array.isArray(value)) {
712
+ const obj = value;
713
+ for (const key of Object.keys(obj)) this._writeFlattenedField(`${path}.${key}`, obj[key], result);
545
714
  }
715
+ } else if (this._jsonFields.has(path)) {
716
+ const physical = this._pathToPhysical.get(path) ?? path.replace(/\./g, "__");
717
+ result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
718
+ } else {
719
+ const physical = this._pathToPhysical.get(path) ?? path.replace(/\./g, "__");
720
+ result[physical] = value;
546
721
  }
547
722
  }
548
723
  /**
@@ -555,11 +730,11 @@ else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject
555
730
  * Reconstructs nested objects from flat __-separated column values.
556
731
  * JSON fields are parsed from strings back to objects/arrays.
557
732
  */ _reconstructFromRead(row) {
558
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return row;
733
+ if (!this._requiresMappings || this._nestedObjects) return this._coerceBooleans(row);
559
734
  const result = {};
560
735
  const rowKeys = Object.keys(row);
561
736
  for (const physical of rowKeys) {
562
- const value = row[physical];
737
+ const value = this._booleanFields.has(physical) ? toBool(row[physical]) : row[physical];
563
738
  const logicalPath = this._physicalToPath.get(physical);
564
739
  if (!logicalPath) {
565
740
  result[physical] = value;
@@ -575,6 +750,14 @@ else result[logicalPath] = value;
575
750
  return result;
576
751
  }
577
752
  /**
753
+ * Coerces boolean fields from storage representation (0/1) to JS booleans.
754
+ * Used on the fast-path when no column mapping is needed.
755
+ */ _coerceBooleans(row) {
756
+ if (this._booleanFields.size === 0) return row;
757
+ for (const field of this._booleanFields) if (field in row) row[field] = toBool(row[field]);
758
+ return row;
759
+ }
760
+ /**
578
761
  * Sets a value at a dot-notation path, creating intermediate objects as needed.
579
762
  */ _setNestedValue(obj, dotPath, value) {
580
763
  const parts = dotPath.split(".");
@@ -615,12 +798,21 @@ else result[logicalPath] = value;
615
798
  /**
616
799
  * Translates a Uniquery's filter, sort, and projection from logical
617
800
  * dot-notation paths to physical column names.
801
+ * Always wraps `$select` in {@link UniquSelect}.
618
802
  */ _translateQuery(query) {
619
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return query;
803
+ if (!this._requiresMappings || this._nestedObjects) {
804
+ const controls = query.controls;
805
+ return {
806
+ filter: query.filter,
807
+ controls: {
808
+ ...controls,
809
+ $select: controls?.$select ? new UniquSelect(controls.$select, this._allPhysicalFields) : undefined
810
+ }
811
+ };
812
+ }
620
813
  return {
621
814
  filter: this._translateFilter(query.filter),
622
- controls: query.controls ? this._translateControls(query.controls) : query.controls,
623
- insights: query.insights
815
+ controls: query.controls ? this._translateControls(query.controls) : {}
624
816
  };
625
817
  }
626
818
  /**
@@ -644,9 +836,13 @@ else {
644
836
  }
645
837
  /**
646
838
  * Translates field names in sort and projection controls.
839
+ * Wraps `$select` in {@link UniquSelect} after path translation.
647
840
  */ _translateControls(controls) {
648
- if (!controls) return controls;
649
- const result = { ...controls };
841
+ if (!controls) return {};
842
+ const result = {
843
+ ...controls,
844
+ $select: undefined
845
+ };
650
846
  if (controls.$sort) {
651
847
  const translated = {};
652
848
  const sortObj = controls.$sort;
@@ -658,35 +854,39 @@ else {
658
854
  }
659
855
  result.$sort = translated;
660
856
  }
661
- if (controls.$select) if (Array.isArray(controls.$select)) {
662
- const expanded = [];
663
- for (const key of controls.$select) {
664
- const expansion = this._selectExpansion.get(key);
665
- if (expansion) expanded.push(...expansion);
857
+ if (controls.$select) {
858
+ let translatedRaw;
859
+ if (Array.isArray(controls.$select)) {
860
+ const expanded = [];
861
+ for (const key of controls.$select) {
862
+ const expansion = this._selectExpansion.get(key);
863
+ if (expansion) expanded.push(...expansion);
666
864
  else expanded.push(this._pathToPhysical.get(key) ?? key);
667
- }
668
- result.$select = expanded;
669
- } else {
670
- const translated = {};
671
- const selectObj = controls.$select;
672
- const selectKeys = Object.keys(selectObj);
673
- for (const key of selectKeys) {
674
- const val = selectObj[key];
675
- const expansion = this._selectExpansion.get(key);
676
- if (expansion) for (const leaf of expansion) translated[leaf] = val;
865
+ }
866
+ translatedRaw = expanded;
867
+ } else {
868
+ const translated = {};
869
+ const selectObj = controls.$select;
870
+ const selectKeys = Object.keys(selectObj);
871
+ for (const key of selectKeys) {
872
+ const val = selectObj[key];
873
+ const expansion = this._selectExpansion.get(key);
874
+ if (expansion) for (const leaf of expansion) translated[leaf] = val;
677
875
  else {
678
- const physical = this._pathToPhysical.get(key) ?? key;
679
- translated[physical] = val;
876
+ const physical = this._pathToPhysical.get(key) ?? key;
877
+ translated[physical] = val;
878
+ }
680
879
  }
880
+ translatedRaw = translated;
681
881
  }
682
- result.$select = translated;
882
+ result.$select = new UniquSelect(translatedRaw, this._allPhysicalFields);
683
883
  }
684
884
  return result;
685
885
  }
686
886
  /**
687
887
  * Translates dot-notation keys in a decomposed patch to physical column names.
688
888
  */ _translatePatchKeys(update) {
689
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return update;
889
+ if (!this._requiresMappings || this._nestedObjects) return update;
690
890
  const result = {};
691
891
  const updateKeys = Object.keys(update);
692
892
  for (const key of updateKeys) {
@@ -719,21 +919,26 @@ else result[finalKey] = value;
719
919
  */ _buildValidator(purpose) {
720
920
  const plugins = this.adapter.getValidatorPlugins();
721
921
  switch (purpose) {
722
- case "insert": return this.createValidator({
723
- plugins,
724
- replace: (type, path) => {
725
- if (this._primaryKeys.includes(path)) return {
726
- ...type,
727
- optional: true
728
- };
729
- return type;
730
- }
731
- });
732
- case "update": return this.createValidator({ plugins });
733
- case "patch": return this.createValidator({
734
- plugins,
735
- partial: true
736
- });
922
+ case "insert": {
923
+ if (this.adapter.buildInsertValidator) return this.adapter.buildInsertValidator(this);
924
+ return this.createValidator({
925
+ plugins,
926
+ replace: (type, path) => {
927
+ if (this._primaryKeys.includes(path) || this._defaults.has(path)) return {
928
+ ...type,
929
+ optional: true
930
+ };
931
+ return type;
932
+ }
933
+ });
934
+ }
935
+ case "patch": {
936
+ if (this.adapter.buildPatchValidator) return this.adapter.buildPatchValidator(this);
937
+ return this.createValidator({
938
+ plugins,
939
+ partial: true
940
+ });
941
+ }
737
942
  default: return this.createValidator({ plugins });
738
943
  }
739
944
  }
@@ -756,7 +961,10 @@ else result[finalKey] = value;
756
961
  /** Object paths being flattened into __-separated columns (no column themselves). */ _define_property$1(this, "_flattenedParents", void 0);
757
962
  /** Fields stored as JSON (@db.json + array fields). */ _define_property$1(this, "_jsonFields", void 0);
758
963
  /** Intermediate paths → their leaf physical column names (for $select expansion in relational DBs). */ _define_property$1(this, "_selectExpansion", void 0);
964
+ /** Physical column names of boolean fields (for storage coercion on read). */ _define_property$1(this, "_booleanFields", void 0);
759
965
  /** Fast-path flag: skip all mapping when no nested/json fields exist. */ _define_property$1(this, "_requiresMappings", void 0);
966
+ /** All non-ignored physical field names (for UniquSelect exclusion inversion). */ _define_property$1(this, "_allPhysicalFields", void 0);
967
+ /** Cached result of adapter.supportsNestedObjects(). */ _define_property$1(this, "_nestedObjects", void 0);
760
968
  _define_property$1(this, "validators", void 0);
761
969
  this._type = _type;
762
970
  this.adapter = adapter;
@@ -772,7 +980,9 @@ else result[finalKey] = value;
772
980
  this._flattenedParents = new Set();
773
981
  this._jsonFields = new Set();
774
982
  this._selectExpansion = new Map();
983
+ this._booleanFields = new Set();
775
984
  this._requiresMappings = false;
985
+ this._allPhysicalFields = [];
776
986
  this.validators = new Map();
777
987
  if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
778
988
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -782,6 +992,7 @@ else result[finalKey] = value;
782
992
  this.tableName = adapterName || dbTable || fallbackName;
783
993
  if (!this.tableName) throw new Error("@db.table annotation or adapter-specific table name expected");
784
994
  this.schema = _type.metadata.get("db.schema");
995
+ this._nestedObjects = adapter.supportsNestedObjects();
785
996
  adapter.registerTable(this);
786
997
  }
787
998
  };
@@ -887,10 +1098,50 @@ var BaseDbAdapter = class {
887
1098
  }
888
1099
  for (const name of existingNames) if (!desiredNames.has(name)) await opts.dropIndex(name);
889
1100
  }
1101
+ /**
1102
+ * Returns available search indexes for this adapter.
1103
+ * UI uses this to show index picker. Override in adapters that support search.
1104
+ */ getSearchIndexes() {
1105
+ return [];
1106
+ }
1107
+ /**
1108
+ * Whether this adapter supports text search.
1109
+ * Default: `true` when {@link getSearchIndexes} returns any entries.
1110
+ */ isSearchable() {
1111
+ return this.getSearchIndexes().length > 0;
1112
+ }
1113
+ /**
1114
+ * Full-text search. Override in adapters that support search.
1115
+ *
1116
+ * @param text - Search text.
1117
+ * @param query - Filter, sort, limit, etc.
1118
+ * @param indexName - Optional search index to target.
1119
+ */ async search(text, query, indexName) {
1120
+ throw new Error("Search not supported by this adapter");
1121
+ }
1122
+ /**
1123
+ * Full-text search with count (for paginated search results).
1124
+ *
1125
+ * @param text - Search text.
1126
+ * @param query - Filter, sort, limit, etc.
1127
+ * @param indexName - Optional search index to target.
1128
+ */ async searchWithCount(text, query, indexName) {
1129
+ throw new Error("Search not supported by this adapter");
1130
+ }
1131
+ /**
1132
+ * Fetches records and total count in one call.
1133
+ * Default: two parallel calls. Adapters may override for single-query optimization.
1134
+ */ async findManyWithCount(query) {
1135
+ const [data, count] = await Promise.all([this.findMany(query), this.count(query)]);
1136
+ return {
1137
+ data,
1138
+ count
1139
+ };
1140
+ }
890
1141
  constructor() {
891
1142
  _define_property(this, "_table", void 0);
892
1143
  }
893
1144
  };
894
1145
 
895
1146
  //#endregion
896
- export { AtscriptDbTable, BaseDbAdapter, NoopLogger, decomposePatch, getKeyProps, isPrimitive, resolveDesignType, walkFilter };
1147
+ export { AtscriptDbTable, BaseDbAdapter, NoopLogger, UniquSelect, decomposePatch, getKeyProps, isPrimitive, resolveDesignType, walkFilter };