@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.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;
@@ -179,13 +281,13 @@ var AtscriptDbTable = class {
179
281
  this._flatten();
180
282
  if (!this._fieldDescriptors) {
181
283
  this._fieldDescriptors = [];
182
- const skipFlattening = this.adapter.supportsNestedObjects();
284
+ const skipFlattening = this._nestedObjects;
183
285
  for (const [path, type] of this._flatMap.entries()) {
184
286
  if (!path) continue;
185
287
  if (!skipFlattening && this._flattenedParents.has(path)) continue;
186
- if (!skipFlattening && this._findJsonParent(path) !== undefined) continue;
288
+ if (!skipFlattening && this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
187
289
  const isJson = this._jsonFields.has(path);
188
- const isFlattened = !skipFlattening && this._findFlattenedParent(path) !== undefined;
290
+ const isFlattened = !skipFlattening && this._findAncestorInSet(path, this._flattenedParents) !== undefined;
189
291
  const designType = isJson ? "json" : resolveDesignType(type);
190
292
  let storage;
191
293
  if (skipFlattening) storage = "column";
@@ -206,33 +308,11 @@ else storage = "column";
206
308
  flattenedFrom: isFlattened ? path : undefined
207
309
  });
208
310
  }
311
+ Object.freeze(this._fieldDescriptors);
209
312
  }
210
313
  return this._fieldDescriptors;
211
314
  }
212
315
  /**
213
- * Resolves `$select` from {@link UniqueryControls} to a list of field names.
214
- * - `undefined` → `undefined` (all fields)
215
- * - `string[]` → pass through
216
- * - `Record<K, 1>` → extract included keys
217
- * - `Record<K, 0>` → invert using known field names
218
- */ resolveProjection(select) {
219
- if (!select) return undefined;
220
- if (Array.isArray(select)) return select.length > 0 ? select : undefined;
221
- const selectObj = select;
222
- const keys = Object.keys(selectObj);
223
- if (keys.length === 0) return undefined;
224
- if (selectObj[keys[0]] === 1) {
225
- const result$1 = [];
226
- for (const k of keys) if (selectObj[k] === 1) result$1.push(k);
227
- return result$1;
228
- }
229
- const excluded = new Set();
230
- for (const k of keys) if (selectObj[k] === 0) excluded.add(k);
231
- const result = [];
232
- for (const f of this.fieldDescriptors) if (!f.ignored && !excluded.has(f.path)) result.push(f.path);
233
- return result;
234
- }
235
- /**
236
316
  * Creates a new validator with custom options.
237
317
  * Adapter plugins are NOT automatically included — use {@link getValidator}
238
318
  * for the standard validator with adapter plugins.
@@ -345,6 +425,106 @@ else storage = "column";
345
425
  });
346
426
  return this.adapter.count(this._translateQuery(query));
347
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;
527
+ }
348
528
  async updateMany(filter, data) {
349
529
  this._flatten();
350
530
  return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
@@ -381,9 +561,12 @@ else storage = "column";
381
561
  this.adapter.onFieldScanned?.(path, type, metadata);
382
562
  }
383
563
  });
384
- if (!this.adapter.supportsNestedObjects()) this._classifyFields();
564
+ if (!this._nestedObjects) this._classifyFields();
385
565
  this._finalizeIndexes();
386
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);
387
570
  }
388
571
  /**
389
572
  * Scans `@db.*` and `@meta.id` annotations on a field during flattening.
@@ -405,7 +588,7 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
405
588
  for (const index of metadata.get("db.index.plain") || []) {
406
589
  const name = index === true ? fieldName : index?.name || fieldName;
407
590
  const sort = (index === true ? undefined : index?.sort) || "asc";
408
- this._addIndexField("plain", name, fieldName, sort);
591
+ this._addIndexField("plain", name, fieldName, { sort });
409
592
  }
410
593
  for (const index of metadata.get("db.index.unique") || []) {
411
594
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
@@ -413,7 +596,8 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
413
596
  }
414
597
  for (const index of metadata.get("db.index.fulltext") || []) {
415
598
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
416
- this._addIndexField("fulltext", name, fieldName);
599
+ const weight = index !== true && typeof index === "object" ? index?.weight : undefined;
600
+ this._addIndexField("fulltext", name, fieldName, { weight });
417
601
  }
418
602
  if (metadata.has("db.json")) {
419
603
  this._jsonFields.add(fieldName);
@@ -421,13 +605,14 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
421
605
  if (hasIndex) this.logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
422
606
  }
423
607
  }
424
- _addIndexField(type, name, field, sort = "asc") {
608
+ _addIndexField(type, name, field, opts) {
425
609
  const key = indexKey(type, name);
426
610
  const index = this._indexes.get(key);
427
611
  const indexField = {
428
612
  name: field,
429
- sort
613
+ sort: opts?.sort ?? "asc"
430
614
  };
615
+ if (opts?.weight !== undefined) indexField.weight = opts.weight;
431
616
  if (index) index.fields.push(indexField);
432
617
  else this._indexes.set(key, {
433
618
  key,
@@ -458,11 +643,13 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
458
643
  for (const [path] of this._flatMap.entries()) {
459
644
  if (!path) continue;
460
645
  if (this._flattenedParents.has(path)) continue;
461
- if (this._findJsonParent(path) !== undefined) continue;
462
- const isFlattened = this._findFlattenedParent(path) !== undefined;
646
+ if (this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
647
+ const isFlattened = this._findAncestorInSet(path, this._flattenedParents) !== undefined;
463
648
  const physicalName = this._columnMap.get(path) ?? (isFlattened ? path.replace(/\./g, "__") : path);
464
649
  this._pathToPhysical.set(path, physicalName);
465
650
  this._physicalToPath.set(physicalName, path);
651
+ const fieldType = this._flatMap?.get(path);
652
+ if (fieldType && resolveDesignType(fieldType) === "boolean") this._booleanFields.add(physicalName);
466
653
  }
467
654
  for (const parentPath of this._flattenedParents) {
468
655
  const prefix = `${parentPath}.`;
@@ -473,24 +660,13 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
473
660
  this._requiresMappings = this._flattenedParents.size > 0 || this._jsonFields.size > 0;
474
661
  }
475
662
  /**
476
- * Finds the nearest ancestor that is a flattened parent object.
477
- * Returns the parent path, or undefined if no ancestor is being flattened.
478
- */ _findFlattenedParent(path) {
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) {
479
666
  let pos = path.length;
480
667
  while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
481
668
  const ancestor = path.slice(0, pos);
482
- if (this._flattenedParents.has(ancestor)) return ancestor;
483
- }
484
- return undefined;
485
- }
486
- /**
487
- * Finds the nearest ancestor that is a @db.json field.
488
- * Children of JSON fields don't get their own columns.
489
- */ _findJsonParent(path) {
490
- let pos = path.length;
491
- while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
492
- const ancestor = path.slice(0, pos);
493
- if (this._jsonFields.has(ancestor)) return ancestor;
669
+ if (set.has(ancestor)) return ancestor;
494
670
  }
495
671
  return undefined;
496
672
  }
@@ -503,7 +679,20 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
503
679
  * Called before validation so that defaults satisfy required field constraints.
504
680
  */ _applyDefaults(data) {
505
681
  for (const [field, def] of this._defaults.entries()) if (data[field] === undefined) {
506
- 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
+ }
507
696
  }
508
697
  return data;
509
698
  }
@@ -518,7 +707,7 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
518
707
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
519
708
  }
520
709
  for (const field of this._ignoredFields) if (!field.includes(".")) delete data[field];
521
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) {
710
+ if (!this._requiresMappings || this._nestedObjects) {
522
711
  for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
523
712
  data[physical] = data[logical];
524
713
  delete data[logical];
@@ -533,40 +722,26 @@ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedPar
533
722
  * Uses _pathToPhysical for final key names.
534
723
  */ _flattenPayload(data) {
535
724
  const result = {};
536
- const dataKeys = Object.keys(data);
537
- for (const key of dataKeys) {
538
- const value = data[key];
539
- if (this._flattenedParents.has(key)) {
540
- if (value === null || value === undefined) this._setFlattenedChildrenNull(key, result);
541
- else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(key, value, result);
542
- } else if (this._jsonFields.has(key)) {
543
- const physical = this._pathToPhysical.get(key) ?? key;
544
- result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
545
- } else {
546
- const physical = this._pathToPhysical.get(key) ?? key;
547
- result[physical] = value;
548
- }
549
- }
725
+ for (const key of Object.keys(data)) this._writeFlattenedField(key, data[key], result);
550
726
  return result;
551
727
  }
552
728
  /**
553
- * Recursively flattens a nested object, writing physical keys to result.
554
- */ _flattenObject(prefix, obj, result) {
555
- const objKeys = Object.keys(obj);
556
- for (const key of objKeys) {
557
- const value = obj[key];
558
- const dotPath = `${prefix}.${key}`;
559
- if (this._ignoredFields.has(dotPath)) continue;
560
- if (this._flattenedParents.has(dotPath)) {
561
- if (value === null || value === undefined) this._setFlattenedChildrenNull(dotPath, result);
562
- else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(dotPath, value, result);
563
- } else if (this._jsonFields.has(dotPath)) {
564
- const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
565
- result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
566
- } else {
567
- const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
568
- result[physical] = value;
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);
569
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;
570
745
  }
571
746
  }
572
747
  /**
@@ -579,11 +754,11 @@ else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject
579
754
  * Reconstructs nested objects from flat __-separated column values.
580
755
  * JSON fields are parsed from strings back to objects/arrays.
581
756
  */ _reconstructFromRead(row) {
582
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return row;
757
+ if (!this._requiresMappings || this._nestedObjects) return this._coerceBooleans(row);
583
758
  const result = {};
584
759
  const rowKeys = Object.keys(row);
585
760
  for (const physical of rowKeys) {
586
- const value = row[physical];
761
+ const value = this._booleanFields.has(physical) ? toBool(row[physical]) : row[physical];
587
762
  const logicalPath = this._physicalToPath.get(physical);
588
763
  if (!logicalPath) {
589
764
  result[physical] = value;
@@ -599,6 +774,14 @@ else result[logicalPath] = value;
599
774
  return result;
600
775
  }
601
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
+ /**
602
785
  * Sets a value at a dot-notation path, creating intermediate objects as needed.
603
786
  */ _setNestedValue(obj, dotPath, value) {
604
787
  const parts = dotPath.split(".");
@@ -639,12 +822,21 @@ else result[logicalPath] = value;
639
822
  /**
640
823
  * Translates a Uniquery's filter, sort, and projection from logical
641
824
  * dot-notation paths to physical column names.
825
+ * Always wraps `$select` in {@link UniquSelect}.
642
826
  */ _translateQuery(query) {
643
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return 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
+ }
644
837
  return {
645
838
  filter: this._translateFilter(query.filter),
646
- controls: query.controls ? this._translateControls(query.controls) : query.controls,
647
- insights: query.insights
839
+ controls: query.controls ? this._translateControls(query.controls) : {}
648
840
  };
649
841
  }
650
842
  /**
@@ -668,9 +860,13 @@ else {
668
860
  }
669
861
  /**
670
862
  * Translates field names in sort and projection controls.
863
+ * Wraps `$select` in {@link UniquSelect} after path translation.
671
864
  */ _translateControls(controls) {
672
- if (!controls) return controls;
673
- const result = { ...controls };
865
+ if (!controls) return {};
866
+ const result = {
867
+ ...controls,
868
+ $select: undefined
869
+ };
674
870
  if (controls.$sort) {
675
871
  const translated = {};
676
872
  const sortObj = controls.$sort;
@@ -682,35 +878,39 @@ else {
682
878
  }
683
879
  result.$sort = translated;
684
880
  }
685
- if (controls.$select) if (Array.isArray(controls.$select)) {
686
- const expanded = [];
687
- for (const key of controls.$select) {
688
- const expansion = this._selectExpansion.get(key);
689
- if (expansion) expanded.push(...expansion);
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);
690
888
  else expanded.push(this._pathToPhysical.get(key) ?? key);
691
- }
692
- result.$select = expanded;
693
- } else {
694
- const translated = {};
695
- const selectObj = controls.$select;
696
- const selectKeys = Object.keys(selectObj);
697
- for (const key of selectKeys) {
698
- const val = selectObj[key];
699
- const expansion = this._selectExpansion.get(key);
700
- if (expansion) for (const leaf of expansion) translated[leaf] = val;
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;
701
899
  else {
702
- const physical = this._pathToPhysical.get(key) ?? key;
703
- translated[physical] = val;
900
+ const physical = this._pathToPhysical.get(key) ?? key;
901
+ translated[physical] = val;
902
+ }
704
903
  }
904
+ translatedRaw = translated;
705
905
  }
706
- result.$select = translated;
906
+ result.$select = new UniquSelect(translatedRaw, this._allPhysicalFields);
707
907
  }
708
908
  return result;
709
909
  }
710
910
  /**
711
911
  * Translates dot-notation keys in a decomposed patch to physical column names.
712
912
  */ _translatePatchKeys(update) {
713
- if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return update;
913
+ if (!this._requiresMappings || this._nestedObjects) return update;
714
914
  const result = {};
715
915
  const updateKeys = Object.keys(update);
716
916
  for (const key of updateKeys) {
@@ -743,21 +943,26 @@ else result[finalKey] = value;
743
943
  */ _buildValidator(purpose) {
744
944
  const plugins = this.adapter.getValidatorPlugins();
745
945
  switch (purpose) {
746
- case "insert": return this.createValidator({
747
- plugins,
748
- replace: (type, path) => {
749
- if (this._primaryKeys.includes(path)) return {
750
- ...type,
751
- optional: true
752
- };
753
- return type;
754
- }
755
- });
756
- case "update": return this.createValidator({ plugins });
757
- case "patch": return this.createValidator({
758
- plugins,
759
- partial: true
760
- });
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
+ }
761
966
  default: return this.createValidator({ plugins });
762
967
  }
763
968
  }
@@ -780,7 +985,10 @@ else result[finalKey] = value;
780
985
  /** Object paths being flattened into __-separated columns (no column themselves). */ _define_property$1(this, "_flattenedParents", void 0);
781
986
  /** Fields stored as JSON (@db.json + array fields). */ _define_property$1(this, "_jsonFields", void 0);
782
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);
783
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);
784
992
  _define_property$1(this, "validators", void 0);
785
993
  this._type = _type;
786
994
  this.adapter = adapter;
@@ -796,7 +1004,9 @@ else result[finalKey] = value;
796
1004
  this._flattenedParents = new Set();
797
1005
  this._jsonFields = new Set();
798
1006
  this._selectExpansion = new Map();
1007
+ this._booleanFields = new Set();
799
1008
  this._requiresMappings = false;
1009
+ this._allPhysicalFields = [];
800
1010
  this.validators = new Map();
801
1011
  if (!(0, __atscript_typescript_utils.isAnnotatedType)(_type)) throw new Error("Atscript Annotated Type expected");
802
1012
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -806,6 +1016,7 @@ else result[finalKey] = value;
806
1016
  this.tableName = adapterName || dbTable || fallbackName;
807
1017
  if (!this.tableName) throw new Error("@db.table annotation or adapter-specific table name expected");
808
1018
  this.schema = _type.metadata.get("db.schema");
1019
+ this._nestedObjects = adapter.supportsNestedObjects();
809
1020
  adapter.registerTable(this);
810
1021
  }
811
1022
  };
@@ -911,6 +1122,46 @@ var BaseDbAdapter = class {
911
1122
  }
912
1123
  for (const name of existingNames) if (!desiredNames.has(name)) await opts.dropIndex(name);
913
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
+ }
914
1165
  constructor() {
915
1166
  _define_property(this, "_table", void 0);
916
1167
  }
@@ -920,6 +1171,7 @@ var BaseDbAdapter = class {
920
1171
  exports.AtscriptDbTable = AtscriptDbTable
921
1172
  exports.BaseDbAdapter = BaseDbAdapter
922
1173
  exports.NoopLogger = NoopLogger
1174
+ exports.UniquSelect = UniquSelect
923
1175
  exports.decomposePatch = decomposePatch
924
1176
  exports.getKeyProps = getKeyProps
925
1177
  Object.defineProperty(exports, 'isPrimitive', {