@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.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;
@@ -132,6 +234,14 @@ var AtscriptDbTable = class {
132
234
  this._flatten();
133
235
  return this._uniqueProps;
134
236
  }
237
+ /** Precomputed logical dot-path → physical column name map. */ get pathToPhysical() {
238
+ this._flatten();
239
+ return this._pathToPhysical;
240
+ }
241
+ /** Precomputed physical column name → logical dot-path map (inverse). */ get physicalToPath() {
242
+ this._flatten();
243
+ return this._physicalToPath;
244
+ }
135
245
  /** Descriptor for the primary ID field(s). */ getIdDescriptor() {
136
246
  this._flatten();
137
247
  return {
@@ -147,39 +257,38 @@ var AtscriptDbTable = class {
147
257
  this._flatten();
148
258
  if (!this._fieldDescriptors) {
149
259
  this._fieldDescriptors = [];
260
+ const skipFlattening = this._nestedObjects;
150
261
  for (const [path, type] of this._flatMap.entries()) {
151
262
  if (!path) continue;
263
+ if (!skipFlattening && this._flattenedParents.has(path)) continue;
264
+ if (!skipFlattening && this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
265
+ const isJson = this._jsonFields.has(path);
266
+ const isFlattened = !skipFlattening && this._findAncestorInSet(path, this._flattenedParents) !== undefined;
267
+ const designType = isJson ? "json" : resolveDesignType(type);
268
+ let storage;
269
+ if (skipFlattening) storage = "column";
270
+ else if (isJson) storage = "json";
271
+ else if (isFlattened) storage = "flattened";
272
+ else storage = "column";
273
+ const physicalName = skipFlattening ? this._columnMap.get(path) ?? path : this._pathToPhysical.get(path) ?? this._columnMap.get(path) ?? path;
152
274
  this._fieldDescriptors.push({
153
275
  path,
154
276
  type,
155
- physicalName: this._columnMap.get(path) ?? path,
156
- designType: resolveDesignType(type),
277
+ physicalName,
278
+ designType,
157
279
  optional: type.optional === true,
158
280
  isPrimaryKey: this._primaryKeys.includes(path),
159
281
  ignored: this._ignoredFields.has(path),
160
- defaultValue: this._defaults.get(path)
282
+ defaultValue: this._defaults.get(path),
283
+ storage,
284
+ flattenedFrom: isFlattened ? path : undefined
161
285
  });
162
286
  }
287
+ Object.freeze(this._fieldDescriptors);
163
288
  }
164
289
  return this._fieldDescriptors;
165
290
  }
166
291
  /**
167
- * Resolves `$select` from {@link UniqueryControls} to a list of field names.
168
- * - `undefined` → `undefined` (all fields)
169
- * - `string[]` → pass through
170
- * - `Record<K, 1>` → extract included keys
171
- * - `Record<K, 0>` → invert using known field names
172
- */ resolveProjection(select) {
173
- if (!select) return undefined;
174
- if (Array.isArray(select)) return select.length > 0 ? select : undefined;
175
- const entries = Object.entries(select);
176
- if (entries.length === 0) return undefined;
177
- const firstVal = entries[0][1];
178
- if (firstVal === 1) return entries.filter(([, v]) => v === 1).map(([k]) => k);
179
- const excluded = new Set(entries.filter(([, v]) => v === 0).map(([k]) => k));
180
- return this.fieldDescriptors.filter((f) => !f.ignored && !excluded.has(f.path)).map((f) => f.path);
181
- }
182
- /**
183
292
  * Creates a new validator with custom options.
184
293
  * Adapter plugins are NOT automatically included — use {@link getValidator}
185
294
  * for the standard validator with adapter plugins.
@@ -242,9 +351,9 @@ var AtscriptDbTable = class {
242
351
  const validator = this.getValidator("patch");
243
352
  if (!validator.validate(payload)) throw new Error("Validation failed for update");
244
353
  const filter = this._extractPrimaryKeyFilter(payload);
245
- if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(filter, payload);
354
+ if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(this._translateFilter(filter), payload);
246
355
  const update = decomposePatch(payload, this);
247
- return this.adapter.updateOne(filter, update);
356
+ return this.adapter.updateOne(this._translateFilter(filter), this._translatePatchKeys(update));
248
357
  }
249
358
  /**
250
359
  * Deletes a single record by primary key value.
@@ -264,35 +373,145 @@ var AtscriptDbTable = class {
264
373
  filter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
265
374
  }
266
375
  }
267
- return this.adapter.deleteOne(filter);
376
+ return this.adapter.deleteOne(this._translateFilter(filter));
268
377
  }
269
378
  /**
270
379
  * Finds a single record matching the query.
271
380
  */ async findOne(query) {
272
- return this.adapter.findOne(query);
381
+ this._flatten();
382
+ const translatedQuery = this._translateQuery(query);
383
+ const result = await this.adapter.findOne(translatedQuery);
384
+ return result ? this._reconstructFromRead(result) : null;
273
385
  }
274
386
  /**
275
387
  * Finds all records matching the query.
276
388
  */ async findMany(query) {
277
- return this.adapter.findMany(query);
389
+ this._flatten();
390
+ const translatedQuery = this._translateQuery(query);
391
+ const results = await this.adapter.findMany(translatedQuery);
392
+ return results.map((row) => this._reconstructFromRead(row));
278
393
  }
279
394
  /**
280
395
  * Counts records matching the query.
281
396
  */ async count(query) {
397
+ this._flatten();
282
398
  query ?? (query = {
283
399
  filter: {},
284
400
  controls: {}
285
401
  });
286
- return this.adapter.count(query);
402
+ return this.adapter.count(this._translateQuery(query));
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;
287
503
  }
288
504
  async updateMany(filter, data) {
289
- return this.adapter.updateMany(filter, data);
505
+ this._flatten();
506
+ return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
290
507
  }
291
508
  async replaceMany(filter, data) {
292
- return this.adapter.replaceMany(filter, data);
509
+ this._flatten();
510
+ return this.adapter.replaceMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
293
511
  }
294
512
  async deleteMany(filter) {
295
- return this.adapter.deleteMany(filter);
513
+ this._flatten();
514
+ return this.adapter.deleteMany(this._translateFilter(filter));
296
515
  }
297
516
  /**
298
517
  * Synchronizes indexes between Atscript definitions and the database.
@@ -318,8 +537,12 @@ var AtscriptDbTable = class {
318
537
  this.adapter.onFieldScanned?.(path, type, metadata);
319
538
  }
320
539
  });
540
+ if (!this._nestedObjects) this._classifyFields();
321
541
  this._finalizeIndexes();
322
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);
323
546
  }
324
547
  /**
325
548
  * Scans `@db.*` and `@meta.id` annotations on a field during flattening.
@@ -341,7 +564,7 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
341
564
  for (const index of metadata.get("db.index.plain") || []) {
342
565
  const name = index === true ? fieldName : index?.name || fieldName;
343
566
  const sort = (index === true ? undefined : index?.sort) || "asc";
344
- this._addIndexField("plain", name, fieldName, sort);
567
+ this._addIndexField("plain", name, fieldName, { sort });
345
568
  }
346
569
  for (const index of metadata.get("db.index.unique") || []) {
347
570
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
@@ -349,16 +572,23 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
349
572
  }
350
573
  for (const index of metadata.get("db.index.fulltext") || []) {
351
574
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
352
- this._addIndexField("fulltext", name, fieldName);
575
+ const weight = index !== true && typeof index === "object" ? index?.weight : undefined;
576
+ this._addIndexField("fulltext", name, fieldName, { weight });
577
+ }
578
+ if (metadata.has("db.json")) {
579
+ this._jsonFields.add(fieldName);
580
+ const hasIndex = metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext");
581
+ if (hasIndex) this.logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
353
582
  }
354
583
  }
355
- _addIndexField(type, name, field, sort = "asc") {
584
+ _addIndexField(type, name, field, opts) {
356
585
  const key = indexKey(type, name);
357
586
  const index = this._indexes.get(key);
358
587
  const indexField = {
359
588
  name: field,
360
- sort
589
+ sort: opts?.sort ?? "asc"
361
590
  };
591
+ if (opts?.weight !== undefined) indexField.weight = opts.weight;
362
592
  if (index) index.fields.push(indexField);
363
593
  else this._indexes.set(key, {
364
594
  key,
@@ -367,7 +597,57 @@ else this._indexes.set(key, {
367
597
  fields: [indexField]
368
598
  });
369
599
  }
600
+ /**
601
+ * Classifies each field as column, flattened, json, or parent-object.
602
+ * Builds the bidirectional _pathToPhysical / _physicalToPath maps.
603
+ * Only called when the adapter does NOT support nested objects natively.
604
+ */ _classifyFields() {
605
+ for (const [path, type] of this._flatMap.entries()) {
606
+ if (!path) continue;
607
+ const designType = resolveDesignType(type);
608
+ const isJson = this._jsonFields.has(path);
609
+ const isArray = designType === "array";
610
+ const isObject = designType === "object";
611
+ if (isArray) this._jsonFields.add(path);
612
+ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedParents.add(path);
613
+ }
614
+ for (const ignoredField of this._ignoredFields) if (this._flattenedParents.has(ignoredField)) {
615
+ const prefix = `${ignoredField}.`;
616
+ for (const path of this._flatMap.keys()) if (path.startsWith(prefix)) this._ignoredFields.add(path);
617
+ }
618
+ 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`);
619
+ for (const [path] of this._flatMap.entries()) {
620
+ if (!path) continue;
621
+ if (this._flattenedParents.has(path)) continue;
622
+ if (this._findAncestorInSet(path, this._jsonFields) !== undefined) continue;
623
+ const isFlattened = this._findAncestorInSet(path, this._flattenedParents) !== undefined;
624
+ const physicalName = this._columnMap.get(path) ?? (isFlattened ? path.replace(/\./g, "__") : path);
625
+ this._pathToPhysical.set(path, physicalName);
626
+ this._physicalToPath.set(physicalName, path);
627
+ const fieldType = this._flatMap?.get(path);
628
+ if (fieldType && resolveDesignType(fieldType) === "boolean") this._booleanFields.add(physicalName);
629
+ }
630
+ for (const parentPath of this._flattenedParents) {
631
+ const prefix = `${parentPath}.`;
632
+ const leaves = [];
633
+ for (const [path, physical] of this._pathToPhysical) if (path.startsWith(prefix)) leaves.push(physical);
634
+ if (leaves.length > 0) this._selectExpansion.set(parentPath, leaves);
635
+ }
636
+ this._requiresMappings = this._flattenedParents.size > 0 || this._jsonFields.size > 0;
637
+ }
638
+ /**
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) {
642
+ let pos = path.length;
643
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
644
+ const ancestor = path.slice(0, pos);
645
+ if (set.has(ancestor)) return ancestor;
646
+ }
647
+ return undefined;
648
+ }
370
649
  _finalizeIndexes() {
650
+ 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;
371
651
  for (const index of this._indexes.values()) if (index.type === "unique" && index.fields.length === 1) this._uniqueProps.add(index.fields[0].name);
372
652
  }
373
653
  /**
@@ -375,13 +655,26 @@ else this._indexes.set(key, {
375
655
  * Called before validation so that defaults satisfy required field constraints.
376
656
  */ _applyDefaults(data) {
377
657
  for (const [field, def] of this._defaults.entries()) if (data[field] === undefined) {
378
- 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
+ }
379
672
  }
380
673
  return data;
381
674
  }
382
675
  /**
383
676
  * Prepares a payload for writing to the database:
384
- * prepares IDs, strips ignored fields, maps column names.
677
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
385
678
  * Defaults should be applied before this via `_applyDefaults`.
386
679
  */ _prepareForWrite(payload) {
387
680
  const data = { ...payload };
@@ -389,12 +682,224 @@ else this._indexes.set(key, {
389
682
  const fieldType = this._flatMap?.get(pk);
390
683
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
391
684
  }
392
- for (const field of this._ignoredFields) delete data[field];
393
- for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
394
- data[physical] = data[logical];
395
- delete data[logical];
685
+ for (const field of this._ignoredFields) if (!field.includes(".")) delete data[field];
686
+ if (!this._requiresMappings || this._nestedObjects) {
687
+ for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
688
+ data[physical] = data[logical];
689
+ delete data[logical];
690
+ }
691
+ return data;
396
692
  }
397
- return data;
693
+ return this._flattenPayload(data);
694
+ }
695
+ /**
696
+ * Flattens nested object fields into __-separated keys and
697
+ * JSON-stringifies @db.json / array fields.
698
+ * Uses _pathToPhysical for final key names.
699
+ */ _flattenPayload(data) {
700
+ const result = {};
701
+ for (const key of Object.keys(data)) this._writeFlattenedField(key, data[key], result);
702
+ return result;
703
+ }
704
+ /**
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);
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;
721
+ }
722
+ }
723
+ /**
724
+ * When a parent object is null/undefined, set all its flattened children to null.
725
+ */ _setFlattenedChildrenNull(parentPath, result) {
726
+ const prefix = `${parentPath}.`;
727
+ for (const [path, physical] of this._pathToPhysical.entries()) if (path.startsWith(prefix)) result[physical] = null;
728
+ }
729
+ /**
730
+ * Reconstructs nested objects from flat __-separated column values.
731
+ * JSON fields are parsed from strings back to objects/arrays.
732
+ */ _reconstructFromRead(row) {
733
+ if (!this._requiresMappings || this._nestedObjects) return this._coerceBooleans(row);
734
+ const result = {};
735
+ const rowKeys = Object.keys(row);
736
+ for (const physical of rowKeys) {
737
+ const value = this._booleanFields.has(physical) ? toBool(row[physical]) : row[physical];
738
+ const logicalPath = this._physicalToPath.get(physical);
739
+ if (!logicalPath) {
740
+ result[physical] = value;
741
+ continue;
742
+ }
743
+ if (this._jsonFields.has(logicalPath)) {
744
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
745
+ this._setNestedValue(result, logicalPath, parsed);
746
+ } else if (logicalPath.includes(".")) this._setNestedValue(result, logicalPath, value);
747
+ else result[logicalPath] = value;
748
+ }
749
+ for (const parentPath of this._flattenedParents) this._reconstructNullParent(result, parentPath);
750
+ return result;
751
+ }
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
+ /**
761
+ * Sets a value at a dot-notation path, creating intermediate objects as needed.
762
+ */ _setNestedValue(obj, dotPath, value) {
763
+ const parts = dotPath.split(".");
764
+ let current = obj;
765
+ for (let i = 0; i < parts.length - 1; i++) {
766
+ const part = parts[i];
767
+ if (current[part] === undefined || current[part] === null) current[part] = {};
768
+ current = current[part];
769
+ }
770
+ current[parts[parts.length - 1]] = value;
771
+ }
772
+ /**
773
+ * If all children of a flattened parent are null, collapse the parent to null.
774
+ */ _reconstructNullParent(obj, parentPath) {
775
+ const parts = parentPath.split(".");
776
+ let current = obj;
777
+ for (let i = 0; i < parts.length - 1; i++) {
778
+ if (current[parts[i]] === undefined) return;
779
+ current = current[parts[i]];
780
+ }
781
+ const lastPart = parts[parts.length - 1];
782
+ const parentObj = current[lastPart];
783
+ if (typeof parentObj !== "object" || parentObj === null) return;
784
+ let allNull = true;
785
+ const parentKeys = Object.keys(parentObj);
786
+ for (const k of parentKeys) {
787
+ const v = parentObj[k];
788
+ if (v !== null && v !== undefined) {
789
+ allNull = false;
790
+ break;
791
+ }
792
+ }
793
+ if (allNull) {
794
+ const parentType = this._flatMap?.get(parentPath);
795
+ current[lastPart] = parentType?.optional ? null : {};
796
+ }
797
+ }
798
+ /**
799
+ * Translates a Uniquery's filter, sort, and projection from logical
800
+ * dot-notation paths to physical column names.
801
+ * Always wraps `$select` in {@link UniquSelect}.
802
+ */ _translateQuery(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
+ }
813
+ return {
814
+ filter: this._translateFilter(query.filter),
815
+ controls: query.controls ? this._translateControls(query.controls) : {}
816
+ };
817
+ }
818
+ /**
819
+ * Recursively translates field names in a filter expression.
820
+ */ _translateFilter(filter) {
821
+ if (!filter || typeof filter !== "object") return filter;
822
+ if (!this._requiresMappings) return filter;
823
+ const result = {};
824
+ const filterKeys = Object.keys(filter);
825
+ for (const key of filterKeys) {
826
+ const value = filter[key];
827
+ if (key === "$and" || key === "$or") result[key] = value.map((f) => this._translateFilter(f));
828
+ else if (key === "$not") result[key] = this._translateFilter(value);
829
+ else if (key.startsWith("$")) result[key] = value;
830
+ else {
831
+ const physical = this._pathToPhysical.get(key) ?? key;
832
+ result[physical] = value;
833
+ }
834
+ }
835
+ return result;
836
+ }
837
+ /**
838
+ * Translates field names in sort and projection controls.
839
+ * Wraps `$select` in {@link UniquSelect} after path translation.
840
+ */ _translateControls(controls) {
841
+ if (!controls) return {};
842
+ const result = {
843
+ ...controls,
844
+ $select: undefined
845
+ };
846
+ if (controls.$sort) {
847
+ const translated = {};
848
+ const sortObj = controls.$sort;
849
+ const sortKeys = Object.keys(sortObj);
850
+ for (const key of sortKeys) {
851
+ if (this._flattenedParents.has(key)) continue;
852
+ const physical = this._pathToPhysical.get(key) ?? key;
853
+ translated[physical] = sortObj[key];
854
+ }
855
+ result.$sort = translated;
856
+ }
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);
864
+ else expanded.push(this._pathToPhysical.get(key) ?? key);
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;
875
+ else {
876
+ const physical = this._pathToPhysical.get(key) ?? key;
877
+ translated[physical] = val;
878
+ }
879
+ }
880
+ translatedRaw = translated;
881
+ }
882
+ result.$select = new UniquSelect(translatedRaw, this._allPhysicalFields);
883
+ }
884
+ return result;
885
+ }
886
+ /**
887
+ * Translates dot-notation keys in a decomposed patch to physical column names.
888
+ */ _translatePatchKeys(update) {
889
+ if (!this._requiresMappings || this._nestedObjects) return update;
890
+ const result = {};
891
+ const updateKeys = Object.keys(update);
892
+ for (const key of updateKeys) {
893
+ const value = update[key];
894
+ const operatorMatch = key.match(/^(.+?)(\.__\$.+)$/);
895
+ const basePath = operatorMatch ? operatorMatch[1] : key;
896
+ const suffix = operatorMatch ? operatorMatch[2] : "";
897
+ const physical = this._pathToPhysical.get(basePath) ?? basePath;
898
+ const finalKey = physical + suffix;
899
+ if (this._jsonFields.has(basePath) && typeof value === "object" && value !== null && !suffix) result[finalKey] = JSON.stringify(value);
900
+ else result[finalKey] = value;
901
+ }
902
+ return result;
398
903
  }
399
904
  /**
400
905
  * Extracts primary key field(s) from a payload to build a filter.
@@ -414,21 +919,26 @@ else this._indexes.set(key, {
414
919
  */ _buildValidator(purpose) {
415
920
  const plugins = this.adapter.getValidatorPlugins();
416
921
  switch (purpose) {
417
- case "insert": return this.createValidator({
418
- plugins,
419
- replace: (type, path) => {
420
- if (this._primaryKeys.includes(path)) return {
421
- ...type,
422
- optional: true
423
- };
424
- return type;
425
- }
426
- });
427
- case "update": return this.createValidator({ plugins });
428
- case "patch": return this.createValidator({
429
- plugins,
430
- partial: true
431
- });
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
+ }
432
942
  default: return this.createValidator({ plugins });
433
943
  }
434
944
  }
@@ -446,6 +956,15 @@ else this._indexes.set(key, {
446
956
  _define_property$1(this, "_defaults", void 0);
447
957
  _define_property$1(this, "_ignoredFields", void 0);
448
958
  _define_property$1(this, "_uniqueProps", void 0);
959
+ /** Logical dot-path → physical column name. */ _define_property$1(this, "_pathToPhysical", void 0);
960
+ /** Physical column name → logical dot-path (inverse). */ _define_property$1(this, "_physicalToPath", void 0);
961
+ /** Object paths being flattened into __-separated columns (no column themselves). */ _define_property$1(this, "_flattenedParents", void 0);
962
+ /** Fields stored as JSON (@db.json + array fields). */ _define_property$1(this, "_jsonFields", void 0);
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);
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);
449
968
  _define_property$1(this, "validators", void 0);
450
969
  this._type = _type;
451
970
  this.adapter = adapter;
@@ -456,6 +975,14 @@ else this._indexes.set(key, {
456
975
  this._defaults = new Map();
457
976
  this._ignoredFields = new Set();
458
977
  this._uniqueProps = new Set();
978
+ this._pathToPhysical = new Map();
979
+ this._physicalToPath = new Map();
980
+ this._flattenedParents = new Set();
981
+ this._jsonFields = new Set();
982
+ this._selectExpansion = new Map();
983
+ this._booleanFields = new Set();
984
+ this._requiresMappings = false;
985
+ this._allPhysicalFields = [];
459
986
  this.validators = new Map();
460
987
  if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
461
988
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -465,6 +992,7 @@ else this._indexes.set(key, {
465
992
  this.tableName = adapterName || dbTable || fallbackName;
466
993
  if (!this.tableName) throw new Error("@db.table annotation or adapter-specific table name expected");
467
994
  this.schema = _type.metadata.get("db.schema");
995
+ this._nestedObjects = adapter.supportsNestedObjects();
468
996
  adapter.registerTable(this);
469
997
  }
470
998
  };
@@ -515,6 +1043,14 @@ var BaseDbAdapter = class {
515
1043
  return false;
516
1044
  }
517
1045
  /**
1046
+ * Whether this adapter handles nested objects natively.
1047
+ * When `true`, the generic layer skips flattening and
1048
+ * passes nested objects as-is to the adapter.
1049
+ * MongoDB returns `true`; relational adapters return `false` (default).
1050
+ */ supportsNestedObjects() {
1051
+ return false;
1052
+ }
1053
+ /**
518
1054
  * Applies a patch payload using native database operations.
519
1055
  * Only called when {@link supportsNativePatch} returns `true`.
520
1056
  *
@@ -562,10 +1098,50 @@ var BaseDbAdapter = class {
562
1098
  }
563
1099
  for (const name of existingNames) if (!desiredNames.has(name)) await opts.dropIndex(name);
564
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
+ }
565
1141
  constructor() {
566
1142
  _define_property(this, "_table", void 0);
567
1143
  }
568
1144
  };
569
1145
 
570
1146
  //#endregion
571
- export { AtscriptDbTable, BaseDbAdapter, NoopLogger, decomposePatch, getKeyProps, isPrimitive, resolveDesignType, walkFilter };
1147
+ export { AtscriptDbTable, BaseDbAdapter, NoopLogger, UniquSelect, decomposePatch, getKeyProps, isPrimitive, resolveDesignType, walkFilter };