@atscript/db 0.1.103 → 0.1.104

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.
@@ -2,7 +2,7 @@ const require_db_error = require("./db-error-DXwEzmYJ.cjs");
2
2
  const require_agg = require("./agg.cjs");
3
3
  const require_nested_writer = require("./nested-writer-DoDhl3X3.cjs");
4
4
  const require_ops = require("./ops.cjs");
5
- const require_validator = require("./validator-f43UcYiY.cjs");
5
+ const require_validator = require("./validator-BSk8lPH1.cjs");
6
6
  let _atscript_typescript_utils = require("@atscript/typescript/utils");
7
7
  let node_async_hooks = require("node:async_hooks");
8
8
  //#region src/logger.ts
@@ -35,6 +35,24 @@ function findAncestorInSet(path, set) {
35
35
  function isNavRelation(metadata) {
36
36
  return metadata.has("db.rel.to") || metadata.has("db.rel.from") || metadata.has("db.rel.via");
37
37
  }
38
+ /** Returns true if the annotated type IS the `db.geoPoint` primitive (tag-based). */
39
+ function isGeoPointType(fieldType) {
40
+ return fieldType.type.tags?.has("geoPoint") === true;
41
+ }
42
+ /**
43
+ * Returns true if the annotated type is acceptable for `@db.index.geo`:
44
+ * the `db.geoPoint` primitive or a structurally identical `number[]`
45
+ * (excluding `db.vector`, which is semantically an embedding).
46
+ */
47
+ function isGeoIndexableType(fieldType) {
48
+ if (isGeoPointType(fieldType)) return true;
49
+ if (fieldType.type.tags?.has("vector")) return false;
50
+ if (fieldType.type.kind === "array") {
51
+ const of = fieldType.type.of;
52
+ return !!of && resolveDesignType(of) === "number";
53
+ }
54
+ return false;
55
+ }
38
56
  /**
39
57
  * Computed metadata for a database table or view.
40
58
  *
@@ -67,6 +85,8 @@ var TableMetadata = class {
67
85
  versionField;
68
86
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
69
87
  quantityRefByField = /* @__PURE__ */ new Map();
88
+ /** Logical paths annotated with `@db.encrypted` — stored as one opaque ciphertext column. */
89
+ encryptedFields = /* @__PURE__ */ new Set();
70
90
  pathToPhysical = /* @__PURE__ */ new Map();
71
91
  physicalToPath = /* @__PURE__ */ new Map();
72
92
  flattenedParents = /* @__PURE__ */ new Set();
@@ -135,6 +155,7 @@ var TableMetadata = class {
135
155
  this._scanGenericAnnotations(entry.path, entry.type, entry.metadata, logger);
136
156
  adapter.onFieldScanned?.(entry.path, entry.type, entry.metadata);
137
157
  }
158
+ for (const path of this.encryptedFields) if (findAncestorInSet(path, this.encryptedFields) !== void 0) this.encryptedFields.delete(path);
138
159
  if (!this.nestedObjects) this._classifyFields();
139
160
  const overrides = adapter.getMetadataOverrides?.(this);
140
161
  if (overrides) this._applyOverrides(overrides);
@@ -247,6 +268,16 @@ var TableMetadata = class {
247
268
  const weight = index !== true && typeof index === "object" ? index?.weight : void 0;
248
269
  this._addIndexField("fulltext", name, fieldName, { weight });
249
270
  }
271
+ if (metadata.has("db.index.geo")) {
272
+ const raw = metadata.get("db.index.geo");
273
+ const name = typeof raw === "string" && raw ? raw : fieldName;
274
+ this._validateGeoIndexField(fieldName, fieldType, metadata);
275
+ this._addIndexField("geo", name, fieldName);
276
+ }
277
+ if (metadata.has("db.encrypted")) {
278
+ this._validateEncryptedField(fieldName, metadata);
279
+ this.encryptedFields.add(fieldName);
280
+ }
250
281
  const collate = metadata.get("db.column.collate");
251
282
  if (collate) this._collateMap.set(fieldName, collate);
252
283
  const hasExplicitIndex = metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext");
@@ -268,6 +299,37 @@ var TableMetadata = class {
268
299
  });
269
300
  }
270
301
  }
302
+ /**
303
+ * Build-time diagnostics for `@db.encrypted` (§6 of the field-encryption
304
+ * spec). Mirrors the compile-time AnnotationSpec validation so models built
305
+ * from pre-compiled types still fail fast.
306
+ */
307
+ _validateEncryptedField(fieldName, metadata) {
308
+ const reject = (what, why) => {
309
+ throw new Error(`@db.encrypted on "${fieldName}" cannot coexist with ${what} — ${why}`);
310
+ };
311
+ if (metadata.has("meta.id")) reject("@meta.id", "the primary key must be addressable");
312
+ if (metadata.has("db.rel.FK")) reject("@db.rel.FK", "joins are impossible over ciphertext");
313
+ if (metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext") || metadata.has("db.index.geo")) reject("@db.index.*", "indexes over ciphertext are meaningless");
314
+ if (metadata.has("db.search.vector") || metadata.has("db.search.filter")) reject("@db.search.*", "search over ciphertext is impossible");
315
+ if (metadata.has("db.mongo.search.text") || metadata.has("db.mongo.search.autocomplete")) reject("@db.mongo.search.*", "Atlas Search over ciphertext is impossible");
316
+ if (metadata.has("db.column.version")) reject("@db.column.version", "the OCC filter needs cleartext equality");
317
+ if (metadata.has("db.default.increment") || metadata.has("db.default.now")) reject("@db.default.increment / @db.default.now", "engine-side defaults bypass the encryption transform");
318
+ if (metadata.get("db.patch.strategy") === "merge") reject("@db.patch.strategy \"merge\"", "ciphertext is opaque — partial merges would silently drop omitted keys");
319
+ }
320
+ /** Build-time diagnostics for `@db.index.geo` (§3 of the geo-index spec). */
321
+ _validateGeoIndexField(fieldName, fieldType, metadata) {
322
+ const reject = (why) => {
323
+ throw new Error(`@db.index.geo on "${fieldName}": ${why}`);
324
+ };
325
+ if (!isGeoIndexableType(fieldType)) reject("the field type must resolve to db.geoPoint (a [lng, lat] number tuple)");
326
+ if (fieldName.includes(".")) reject("geo indexes are only supported on top-level fields in v1");
327
+ if (metadata.has("db.encrypted")) reject("@db.index.geo is mutually exclusive with @db.encrypted");
328
+ if (metadata.has("db.json")) reject("@db.index.geo is mutually exclusive with @db.json");
329
+ if (metadata.has("meta.id")) reject("geo fields cannot be part of the primary key");
330
+ if (metadata.has("db.index.unique")) reject("geo fields cannot be part of a unique index");
331
+ if (metadata.has("db.rel.FK")) reject("geo fields cannot be foreign keys");
332
+ }
271
333
  _addIndexField(type, name, field, opts) {
272
334
  const key = indexKey(type, name);
273
335
  const index = this.indexes.get(key);
@@ -291,6 +353,7 @@ var TableMetadata = class {
291
353
  _classifyFields() {
292
354
  for (const [path, type] of this.flatMap.entries()) {
293
355
  if (!path) continue;
356
+ if (this.encryptedFields.has(path) || findAncestorInSet(path, this.encryptedFields)) continue;
294
357
  const designType = resolveDesignType(type);
295
358
  const isJson = this.jsonFields.has(path);
296
359
  const isArray = designType === "array";
@@ -307,6 +370,7 @@ var TableMetadata = class {
307
370
  if (!path) continue;
308
371
  if (this.flattenedParents.has(path)) continue;
309
372
  if (findAncestorInSet(path, this.jsonFields) !== void 0) continue;
373
+ if (findAncestorInSet(path, this.encryptedFields) !== void 0) continue;
310
374
  const isFlattened = findAncestorInSet(path, this.flattenedParents) !== void 0;
311
375
  const columnOverride = this.columnMap.get(path);
312
376
  let physicalName;
@@ -315,7 +379,7 @@ var TableMetadata = class {
315
379
  this.pathToPhysical.set(path, physicalName);
316
380
  this.physicalToPath.set(physicalName, path);
317
381
  const fieldType = this.flatMap.get(path);
318
- if (fieldType) {
382
+ if (fieldType && !this.encryptedFields.has(path)) {
319
383
  const dt = resolveDesignType(fieldType);
320
384
  if (dt === "boolean") this.booleanFields.add(physicalName);
321
385
  else if (dt === "decimal") this.decimalFields.add(physicalName);
@@ -371,11 +435,15 @@ var TableMetadata = class {
371
435
  if (!path) continue;
372
436
  if (!skipFlattening && this.flattenedParents.has(path)) continue;
373
437
  if (!skipFlattening && findAncestorInSet(path, this.jsonFields) !== void 0) continue;
438
+ const isEncrypted = this.encryptedFields.has(path);
439
+ const underEncrypted = findAncestorInSet(path, this.encryptedFields) !== void 0;
440
+ if (!skipFlattening && underEncrypted) continue;
374
441
  const isJson = this.jsonFields.has(path);
375
442
  const isFlattened = !skipFlattening && findAncestorInSet(path, this.flattenedParents) !== void 0;
376
- const designType = isJson ? "json" : resolveDesignType(type);
443
+ const designType = isEncrypted ? "string" : isJson ? "json" : resolveDesignType(type);
377
444
  let storage;
378
445
  if (skipFlattening) storage = "column";
446
+ else if (isEncrypted) storage = isFlattened ? "flattened" : "column";
379
447
  else if (isJson) storage = "json";
380
448
  else if (isFlattened) storage = "flattened";
381
449
  else storage = "column";
@@ -406,7 +474,9 @@ var TableMetadata = class {
406
474
  currencyCode,
407
475
  currencyRefField,
408
476
  unitCode,
409
- unitRefField
477
+ unitRefField,
478
+ encrypted: isEncrypted || underEncrypted || void 0,
479
+ isGeoPoint: isGeoPointType(type) || void 0
410
480
  });
411
481
  }
412
482
  this._resolveFkTargetFields(descriptors);
@@ -458,6 +528,7 @@ var TableMetadata = class {
458
528
  const targetFieldType = targetFlatMap.get(target.targetField);
459
529
  if (!targetFieldType) continue;
460
530
  const targetMetadata = targetFieldType.metadata;
531
+ if (targetMetadata?.has("db.encrypted")) throw new Error(`FK field "${descriptor.path}" references encrypted field "${target.targetField}" — joins are impossible over ciphertext`);
461
532
  descriptor.fkTargetField = {
462
533
  path: target.targetField,
463
534
  type: targetFieldType,
@@ -749,7 +820,7 @@ var FieldMappingStrategy = class {
749
820
  const fmt = meta.toStorageFormatters?.get(physicalName);
750
821
  if (!fmt) return value;
751
822
  if (value === null || value === void 0) return value;
752
- if (typeof value !== "object") return fmt(value);
823
+ if (typeof value !== "object" || Array.isArray(value)) return fmt(value);
753
824
  const ops = value;
754
825
  const formatted = {};
755
826
  for (const [op, opVal] of Object.entries(ops)) if ((op === "$in" || op === "$nin") && Array.isArray(opVal)) formatted[op] = opVal.map((v) => v === null || v === void 0 ? v : fmt(v));
@@ -1080,6 +1151,104 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1080
1151
  }
1081
1152
  };
1082
1153
  //#endregion
1154
+ //#region src/query/query-guards.ts
1155
+ /**
1156
+ * Engine-agnostic query-time guards, applied in the core layer BEFORE filter
1157
+ * translation (field-encryption spec §6, geo-index spec §4.2):
1158
+ *
1159
+ * - filters referencing an `@db.encrypted` field (incl. nested paths into an
1160
+ * encrypted object) → `ENC_FIELD_FILTER`
1161
+ * - `$sort` on an encrypted field → `ENC_FIELD_SORT`
1162
+ * - `$groupBy` / aggregate refs on an encrypted field → `ENC_FIELD_AGG`
1163
+ * - `$geoWithin` on a non-geoPoint field → `FILTER_TYPE_MISMATCH`
1164
+ * - `$geoWithin` with a malformed circle → `INVALID_QUERY`
1165
+ * - `$geoWithin` on an adapter without geo support → `GEO_NOT_SUPPORTED`
1166
+ */
1167
+ /** Validates a `[lng, lat]` tuple (GeoJSON coordinate order). */
1168
+ function assertGeoPoint(point, path) {
1169
+ if (!(Array.isArray(point) && point.length === 2 && typeof point[0] === "number" && typeof point[1] === "number" && Number.isFinite(point[0]) && Number.isFinite(point[1]) && point[0] >= -180 && point[0] <= 180 && point[1] >= -90 && point[1] <= 90)) throw new require_db_error.DbError("INVALID_QUERY", [{
1170
+ path,
1171
+ message: `Invalid geo point at "${path}" — expected [lng, lat] with lng ∈ [-180, 180], lat ∈ [-90, 90]`
1172
+ }]);
1173
+ }
1174
+ function isEncryptedRef(meta, field) {
1175
+ return meta.encryptedFields.has(field) || findAncestorInSet(field, meta.encryptedFields) !== void 0;
1176
+ }
1177
+ function encryptedRefError(code, field, what) {
1178
+ return new require_db_error.DbError(code, [{
1179
+ path: field,
1180
+ message: `Cannot ${what} encrypted field "${field}"`
1181
+ }]);
1182
+ }
1183
+ function guardGeoWithin(meta, adapter, field, value) {
1184
+ const fieldType = meta.flatMap?.get(field);
1185
+ if (!fieldType || !isGeoPointType(fieldType)) throw new require_db_error.DbError("FILTER_TYPE_MISMATCH", [{
1186
+ path: field,
1187
+ message: `$geoWithin requires a db.geoPoint field; "${field}" is not one`
1188
+ }]);
1189
+ const circle = value;
1190
+ if (typeof circle !== "object" || circle === null || Array.isArray(circle)) throw new require_db_error.DbError("INVALID_QUERY", [{
1191
+ path: field,
1192
+ message: "$geoWithin expects { center: [lng, lat], radius: meters }"
1193
+ }]);
1194
+ assertGeoPoint(circle.center, `${field}.$geoWithin.center`);
1195
+ if (typeof circle.radius !== "number" || !Number.isFinite(circle.radius) || circle.radius <= 0) throw new require_db_error.DbError("INVALID_QUERY", [{
1196
+ path: field,
1197
+ message: "$geoWithin radius must be a positive number of meters"
1198
+ }]);
1199
+ if (!adapter.isGeoSearchable()) throw new require_db_error.DbError("GEO_NOT_SUPPORTED", [{
1200
+ path: field,
1201
+ message: "$geoWithin is not supported by this adapter"
1202
+ }]);
1203
+ }
1204
+ /**
1205
+ * Walks a filter expression, rejecting encrypted-field references and
1206
+ * validating `$geoWithin` operator nodes.
1207
+ */
1208
+ function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
1209
+ if (!filter || typeof filter !== "object") return;
1210
+ const hasEncrypted = meta.encryptedFields.size > 0;
1211
+ for (const [key, value] of Object.entries(filter)) {
1212
+ if (key === "$and" || key === "$or") {
1213
+ for (const child of value ?? []) guardFilter(meta, adapter, child, encCode);
1214
+ continue;
1215
+ }
1216
+ if (key === "$not") {
1217
+ guardFilter(meta, adapter, value, encCode);
1218
+ continue;
1219
+ }
1220
+ if (key.startsWith("$")) continue;
1221
+ if (hasEncrypted && isEncryptedRef(meta, key)) throw encryptedRefError(encCode, key, "filter on");
1222
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1223
+ for (const [op, opValue] of Object.entries(value)) if (op === "$geoWithin") guardGeoWithin(meta, adapter, key, opValue);
1224
+ }
1225
+ }
1226
+ }
1227
+ /** Rejects `$sort` keys referencing encrypted fields. */
1228
+ function guardSort(meta, sort) {
1229
+ if (!sort || typeof sort !== "object" || meta.encryptedFields.size === 0) return;
1230
+ for (const key of Object.keys(sort)) if (isEncryptedRef(meta, key)) throw encryptedRefError("ENC_FIELD_SORT", key, "sort by");
1231
+ }
1232
+ /** Shared read-path guard: filter + $sort. */
1233
+ function guardQuery(meta, adapter, query) {
1234
+ if (!query) return;
1235
+ guardFilter(meta, adapter, query.filter);
1236
+ guardSort(meta, query.controls?.$sort);
1237
+ }
1238
+ /** Aggregate-path guard: $groupBy / $select / $having refs + filter + $sort. */
1239
+ function guardAggregate(meta, adapter, query) {
1240
+ guardFilter(meta, adapter, query.filter);
1241
+ if (meta.encryptedFields.size === 0) return;
1242
+ const controls = query.controls;
1243
+ for (const field of controls.$groupBy ?? []) if (isEncryptedRef(meta, field)) throw encryptedRefError("ENC_FIELD_AGG", field, "group by");
1244
+ if (controls.$select) for (const item of controls.$select) {
1245
+ const field = typeof item === "string" ? item : item.$field;
1246
+ if (field !== "*" && isEncryptedRef(meta, field)) throw encryptedRefError("ENC_FIELD_AGG", field, "aggregate over");
1247
+ }
1248
+ if (controls.$having) guardFilter(meta, adapter, controls.$having, "ENC_FIELD_AGG");
1249
+ guardSort(meta, controls.$sort);
1250
+ }
1251
+ //#endregion
1083
1252
  //#region src/table/db-readable.ts
1084
1253
  /**
1085
1254
  * Resolves the design type from an annotated type.
@@ -1174,6 +1343,8 @@ var AtscriptDbReadable = class {
1174
1343
  /** Strategy for mapping between logical field shapes and physical storage. */
1175
1344
  _fieldMapper;
1176
1345
  _writeTableResolver;
1346
+ /** Encryption service for `@db.encrypted` fields — set by `DbSpace` from its options. */
1347
+ _encryption;
1177
1348
  _metaIdPhysical;
1178
1349
  constructor(_type, adapter, logger = NoopLogger, _tableResolver) {
1179
1350
  this._type = _type;
@@ -1195,9 +1366,21 @@ var AtscriptDbReadable = class {
1195
1366
  this._fieldMapper = adapter.supportsNestedObjects() ? new DocumentFieldMapper() : new RelationalFieldMapper();
1196
1367
  adapter.registerReadable(this, logger);
1197
1368
  }
1369
+ /**
1370
+ * Sets the encryption service used for `@db.encrypted` fields.
1371
+ * Called by `DbSpace` after table/view creation when the space was
1372
+ * configured with an `encryption` options block.
1373
+ */
1374
+ setEncryption(encryption) {
1375
+ this._encryption = encryption;
1376
+ }
1198
1377
  /** Ensures metadata is built. Called before any metadata access. */
1199
1378
  _ensureBuilt() {
1200
1379
  if (!this._meta.isBuilt) this._meta.build(this.type, this.adapter, this.logger);
1380
+ if (this._meta.encryptedFields.size > 0 && !this._encryption) throw new require_db_error.DbError("ENC_CONFIG_MISSING", [{
1381
+ path: "",
1382
+ message: `Table "${this.tableName}" declares @db.encrypted fields but the DbSpace has no encryption configuration — pass { encryption: { defaultKeyId, keys } } to the DbSpace options`
1383
+ }]);
1201
1384
  }
1202
1385
  /**
1203
1386
  * Built table metadata. Triggers a lazy build on first access — safe to call
@@ -1214,6 +1397,65 @@ var AtscriptDbReadable = class {
1214
1397
  message: `Table "${this.tableName}" has no search indexes defined`
1215
1398
  }]);
1216
1399
  }
1400
+ /** Engine-agnostic query-time guards (encrypted-field refs, $geoWithin shape). */
1401
+ _guardQuery(query) {
1402
+ guardQuery(this._meta, this.adapter, query);
1403
+ }
1404
+ _encryptedPathsCache;
1405
+ /** Pre-split `encryptedFields` paths — computed once, reused on every read/write. */
1406
+ get _encryptedPaths() {
1407
+ return this._encryptedPathsCache ??= [...this._meta.encryptedFields].map((path) => {
1408
+ const segments = path.split(".");
1409
+ return {
1410
+ path,
1411
+ segments,
1412
+ leaf: segments[segments.length - 1]
1413
+ };
1414
+ });
1415
+ }
1416
+ /**
1417
+ * Walks all but the last of `segments` down from `root`, returning the
1418
+ * object holding the leaf — or `undefined` when the path is unreachable
1419
+ * (a missing, non-object, or array step). With `cloneParents`, every
1420
+ * traversed object is shallow-cloned and re-linked so caller-shared
1421
+ * nested objects are never mutated.
1422
+ */
1423
+ _walkToLeafParent(root, segments, cloneParents) {
1424
+ let parent = root;
1425
+ for (let i = 0; i < segments.length - 1; i++) {
1426
+ const next = parent[segments[i]];
1427
+ if (next === null || typeof next !== "object" || Array.isArray(next)) return;
1428
+ let child = next;
1429
+ if (cloneParents) {
1430
+ child = { ...child };
1431
+ parent[segments[i]] = child;
1432
+ }
1433
+ parent = child;
1434
+ }
1435
+ return parent;
1436
+ }
1437
+ /**
1438
+ * Decrypts `@db.encrypted` fields on reconstructed rows (in place).
1439
+ * Non-envelope stored values follow the configured `onUnencrypted` policy.
1440
+ */
1441
+ async _decryptRows(rows) {
1442
+ const enc = this._encryption;
1443
+ if (this._meta.encryptedFields.size === 0 || rows.length === 0 || !enc) return;
1444
+ for (const row of rows) for (const { path, segments, leaf } of this._encryptedPaths) {
1445
+ const parent = this._walkToLeafParent(row, segments, false);
1446
+ if (!parent) continue;
1447
+ const value = parent[leaf];
1448
+ if (value === void 0 || value === null) continue;
1449
+ if (enc.isEnvelope(value)) parent[leaf] = await enc.decrypt(value, {
1450
+ table: this.tableName,
1451
+ field: path
1452
+ });
1453
+ else if (enc.onUnencrypted === "error") throw new require_db_error.DbError("ENC_NOT_ENCRYPTED", [{
1454
+ path,
1455
+ message: `Field "${path}" on "${this.tableName}" holds a non-encrypted value while @db.encrypted is declared — set encryption.onUnencrypted: 'passthrough' to read legacy plaintext during a migration window`
1456
+ }]);
1457
+ }
1458
+ }
1217
1459
  /** Whether this readable is a view (overridden in AtscriptDbView). */
1218
1460
  get isView() {
1219
1461
  return false;
@@ -1412,11 +1654,13 @@ var AtscriptDbReadable = class {
1412
1654
  */
1413
1655
  async findOne(query) {
1414
1656
  this._ensureBuilt();
1657
+ this._guardQuery(query);
1415
1658
  const withRelations = query.controls?.$with;
1416
1659
  const translatedQuery = this._fieldMapper.translateQuery(query, this._meta);
1417
1660
  const result = await this.adapter.findOne(translatedQuery);
1418
1661
  if (!result) return null;
1419
1662
  const row = this._fieldMapper.reconstructFromRead(result, this._meta);
1663
+ await this._decryptRows([row]);
1420
1664
  if (withRelations?.length) await this.loadRelations([row], withRelations);
1421
1665
  return row;
1422
1666
  }
@@ -1427,9 +1671,11 @@ var AtscriptDbReadable = class {
1427
1671
  */
1428
1672
  async findMany(query) {
1429
1673
  this._ensureBuilt();
1674
+ this._guardQuery(query);
1430
1675
  const withRelations = query.controls?.$with;
1431
1676
  const translatedQuery = this._fieldMapper.translateQuery(query, this._meta);
1432
1677
  const rows = (await this.adapter.findMany(translatedQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1678
+ await this._decryptRows(rows);
1433
1679
  if (withRelations?.length) await this.loadRelations(rows, withRelations);
1434
1680
  return rows;
1435
1681
  }
@@ -1442,6 +1688,7 @@ var AtscriptDbReadable = class {
1442
1688
  filter: {},
1443
1689
  controls: {}
1444
1690
  };
1691
+ this._guardQuery(query);
1445
1692
  return this.adapter.count(this._fieldMapper.translateQuery(query, this._meta));
1446
1693
  }
1447
1694
  /**
@@ -1449,10 +1696,12 @@ var AtscriptDbReadable = class {
1449
1696
  */
1450
1697
  async findManyWithCount(query) {
1451
1698
  this._ensureBuilt();
1699
+ this._guardQuery(query);
1452
1700
  const withRelations = query.controls?.$with;
1453
1701
  const translated = this._fieldMapper.translateQuery(query, this._meta);
1454
1702
  const result = await this.adapter.findManyWithCount(translated);
1455
1703
  const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1704
+ await this._decryptRows(rows);
1456
1705
  if (withRelations?.length) await this.loadRelations(rows, withRelations);
1457
1706
  return {
1458
1707
  data: rows,
@@ -1508,6 +1757,7 @@ var AtscriptDbReadable = class {
1508
1757
  }]);
1509
1758
  }
1510
1759
  }
1760
+ guardAggregate(this._meta, this.adapter, query);
1511
1761
  const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta);
1512
1762
  return (await this.adapter.aggregate(dbQuery)).map((row) => {
1513
1763
  const mapped = {};
@@ -1541,9 +1791,11 @@ var AtscriptDbReadable = class {
1541
1791
  async search(text, query, indexName) {
1542
1792
  this._ensureBuilt();
1543
1793
  this._ensureSearchable();
1794
+ this._guardQuery(query);
1544
1795
  const withRelations = query.controls?.$with;
1545
1796
  const translated = this._fieldMapper.translateQuery(query, this._meta);
1546
1797
  const rows = (await this.adapter.search(text, translated, indexName)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1798
+ await this._decryptRows(rows);
1547
1799
  if (withRelations?.length) await this.loadRelations(rows, withRelations);
1548
1800
  return rows;
1549
1801
  }
@@ -1553,10 +1805,12 @@ var AtscriptDbReadable = class {
1553
1805
  async searchWithCount(text, query, indexName) {
1554
1806
  this._ensureBuilt();
1555
1807
  this._ensureSearchable();
1808
+ this._guardQuery(query);
1556
1809
  const withRelations = query.controls?.$with;
1557
1810
  const translated = this._fieldMapper.translateQuery(query, this._meta);
1558
1811
  const result = await this.adapter.searchWithCount(text, translated, indexName);
1559
1812
  const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1813
+ await this._decryptRows(rows);
1560
1814
  if (withRelations?.length) await this.loadRelations(rows, withRelations);
1561
1815
  return {
1562
1816
  data: rows,
@@ -1577,9 +1831,11 @@ var AtscriptDbReadable = class {
1577
1831
  async vectorSearch(vectorOrIndex, maybeVectorOrQuery, maybeQuery) {
1578
1832
  const { vector, query, indexName } = this._resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery);
1579
1833
  this._ensureBuilt();
1834
+ this._guardQuery(query);
1580
1835
  const withRelations = (query?.controls)?.$with;
1581
1836
  const translated = this._fieldMapper.translateQuery(query || {}, this._meta);
1582
1837
  const rows = (await this.adapter.vectorSearch(vector, translated, indexName)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1838
+ await this._decryptRows(rows);
1583
1839
  if (withRelations?.length) await this.loadRelations(rows, withRelations);
1584
1840
  return rows;
1585
1841
  }
@@ -1593,10 +1849,12 @@ var AtscriptDbReadable = class {
1593
1849
  async vectorSearchWithCount(vectorOrIndex, maybeVectorOrQuery, maybeQuery) {
1594
1850
  const { vector, query, indexName } = this._resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery);
1595
1851
  this._ensureBuilt();
1852
+ this._guardQuery(query);
1596
1853
  const withRelations = (query?.controls)?.$with;
1597
1854
  const translated = this._fieldMapper.translateQuery(query || {}, this._meta);
1598
1855
  const result = await this.adapter.vectorSearchWithCount(vector, translated, indexName);
1599
1856
  const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1857
+ await this._decryptRows(rows);
1600
1858
  if (withRelations?.length) await this.loadRelations(rows, withRelations);
1601
1859
  return {
1602
1860
  data: rows,
@@ -1616,6 +1874,96 @@ var AtscriptDbReadable = class {
1616
1874
  indexName: vectorOrIndex
1617
1875
  };
1618
1876
  }
1877
+ /** Whether the underlying adapter supports geospatial search. */
1878
+ isGeoSearchable() {
1879
+ return this.adapter.isGeoSearchable();
1880
+ }
1881
+ /**
1882
+ * Distance-ranked geospatial search (mirrors {@link vectorSearch}).
1883
+ * Results are sorted by distance ascending; each row carries a computed
1884
+ * `$distance` field (meters from the query point). `$maxDistance` /
1885
+ * `$minDistance` (meters) ride in `query.controls`; user `$sort` is rejected.
1886
+ *
1887
+ * Overloads:
1888
+ * - `geoSearch(point, query?)` — uses the table's only geo index
1889
+ * - `geoSearch(indexName, point, query?)` — targets a specific geo index
1890
+ */
1891
+ async geoSearch(pointOrIndex, maybePointOrQuery, maybeQuery) {
1892
+ const { point, query, indexName } = this._resolveGeoSearchArgs(pointOrIndex, maybePointOrQuery, maybeQuery);
1893
+ const { translated, withRelations } = this._prepareGeoSearch(point, query, indexName);
1894
+ const rows = (await this.adapter.geoSearch(point, translated, indexName)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1895
+ await this._decryptRows(rows);
1896
+ if (withRelations?.length) await this.loadRelations(rows, withRelations);
1897
+ return rows;
1898
+ }
1899
+ /**
1900
+ * Distance-ranked geospatial search with count for paginated results.
1901
+ *
1902
+ * Overloads:
1903
+ * - `geoSearchWithCount(point, query?)` — uses the table's only geo index
1904
+ * - `geoSearchWithCount(indexName, point, query?)` — targets a specific geo index
1905
+ */
1906
+ async geoSearchWithCount(pointOrIndex, maybePointOrQuery, maybeQuery) {
1907
+ const { point, query, indexName } = this._resolveGeoSearchArgs(pointOrIndex, maybePointOrQuery, maybeQuery);
1908
+ const { translated, withRelations } = this._prepareGeoSearch(point, query, indexName);
1909
+ const result = await this.adapter.geoSearchWithCount(point, translated, indexName);
1910
+ const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1911
+ await this._decryptRows(rows);
1912
+ if (withRelations?.length) await this.loadRelations(rows, withRelations);
1913
+ return {
1914
+ data: rows,
1915
+ count: result.count
1916
+ };
1917
+ }
1918
+ /** Resolves overloaded geo search arguments into canonical form. */
1919
+ _resolveGeoSearchArgs(pointOrIndex, maybePointOrQuery, maybeQuery) {
1920
+ if (Array.isArray(pointOrIndex)) return {
1921
+ point: pointOrIndex,
1922
+ query: maybePointOrQuery,
1923
+ indexName: void 0
1924
+ };
1925
+ return {
1926
+ point: maybePointOrQuery,
1927
+ query: maybeQuery,
1928
+ indexName: pointOrIndex
1929
+ };
1930
+ }
1931
+ /** Shared geoSearch validation + query translation. */
1932
+ _prepareGeoSearch(point, query, indexName) {
1933
+ this._ensureBuilt();
1934
+ if (!this.adapter.isGeoSearchable()) throw new require_db_error.DbError("GEO_NOT_SUPPORTED", [{
1935
+ path: "",
1936
+ message: `Geo search is not supported by the adapter behind table "${this.tableName}"`
1937
+ }]);
1938
+ const geoIndexes = [...this._meta.indexes.values()].filter((index) => index.type === "geo");
1939
+ if (geoIndexes.length === 0) throw new require_db_error.DbError("GEO_INDEX_MISSING", [{
1940
+ path: "",
1941
+ message: `Table "${this.tableName}" declares no @db.index.geo — geoSearch requires a geo index`
1942
+ }]);
1943
+ if (indexName !== void 0 && !geoIndexes.some((index) => index.name === indexName)) throw new require_db_error.DbError("GEO_INDEX_MISSING", [{
1944
+ path: indexName,
1945
+ message: `Geo index "${indexName}" not found on table "${this.tableName}"`
1946
+ }]);
1947
+ assertGeoPoint(point, "$center");
1948
+ const controls = query?.controls ?? {};
1949
+ if (controls.$sort) throw new require_db_error.DbError("INVALID_QUERY", [{
1950
+ path: "$sort",
1951
+ message: "geoSearch results are distance-ordered — $sort is not allowed on this path"
1952
+ }]);
1953
+ for (const key of ["$maxDistance", "$minDistance"]) {
1954
+ const v = controls[key];
1955
+ if (v !== void 0 && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) throw new require_db_error.DbError("INVALID_QUERY", [{
1956
+ path: key,
1957
+ message: `${key} must be a non-negative number of meters`
1958
+ }]);
1959
+ }
1960
+ this._guardQuery(query);
1961
+ const withRelations = (query?.controls)?.$with;
1962
+ return {
1963
+ translated: this._fieldMapper.translateQuery(query || {}, this._meta),
1964
+ withRelations
1965
+ };
1966
+ }
1619
1967
  /**
1620
1968
  * Finds a single record by any type-compatible identifier — primary key
1621
1969
  * or single-field unique index.
@@ -1952,6 +2300,7 @@ var BaseDbAdapter = class {
1952
2300
  * even when the field carries `@db.column.filterable`.
1953
2301
  */
1954
2302
  canFilterField(fd) {
2303
+ if (fd.encrypted) return false;
1955
2304
  return fd.storage !== "json";
1956
2305
  }
1957
2306
  /**
@@ -1961,6 +2310,7 @@ var BaseDbAdapter = class {
1961
2310
  * stays conservative even for adapters that technically support it.
1962
2311
  */
1963
2312
  canSortField(fd) {
2313
+ if (fd.encrypted || fd.isGeoPoint) return false;
1964
2314
  return fd.storage !== "json";
1965
2315
  }
1966
2316
  /**
@@ -2037,6 +2387,10 @@ var BaseDbAdapter = class {
2037
2387
  const existingNames = new Set(existing.filter((i) => i.name.startsWith(prefix)).map((i) => i.name));
2038
2388
  const desiredNames = /* @__PURE__ */ new Set();
2039
2389
  for (const index of this._table.indexes.values()) {
2390
+ if (opts.warnUnsupportedTypes?.types.includes(index.type)) {
2391
+ this.logger.warn(`[${opts.warnUnsupportedTypes.adapter}] ${index.type} index "${index.name}" declared but not supported by this adapter — skipped`);
2392
+ continue;
2393
+ }
2040
2394
  if (opts.shouldSkipType?.(index.type)) continue;
2041
2395
  desiredNames.add(index.key);
2042
2396
  if (!existingNames.has(index.key)) await opts.createIndex(index);
@@ -2105,6 +2459,41 @@ var BaseDbAdapter = class {
2105
2459
  throw new Error("Vector search not supported by this adapter");
2106
2460
  }
2107
2461
  /**
2462
+ * Whether this adapter supports geospatial search (`geoSearch()` and the
2463
+ * `$geoWithin` filter operator). Override in adapters that do (MongoDB in v1).
2464
+ */
2465
+ isGeoSearchable() {
2466
+ return false;
2467
+ }
2468
+ /**
2469
+ * Distance-ranked geospatial search. Results sorted by distance ascending;
2470
+ * each row carries a `$distance` field (meters from the query point).
2471
+ * `$maxDistance` / `$minDistance` (meters) ride in `query.controls`.
2472
+ *
2473
+ * @param point - `[lng, lat]` query point (GeoJSON coordinate order).
2474
+ * @param query - Filter, select, skip/limit, $maxDistance/$minDistance.
2475
+ * @param indexName - Optional geo index to target (multi-geo documents).
2476
+ */
2477
+ async geoSearch(_point, _query, _indexName) {
2478
+ throw new require_db_error.DbError("GEO_NOT_SUPPORTED", [{
2479
+ path: "",
2480
+ message: "Geo search not supported by this adapter"
2481
+ }]);
2482
+ }
2483
+ /**
2484
+ * Distance-ranked geospatial search with count (for paginated results).
2485
+ *
2486
+ * @param point - `[lng, lat]` query point (GeoJSON coordinate order).
2487
+ * @param query - Filter, select, skip/limit, $maxDistance/$minDistance.
2488
+ * @param indexName - Optional geo index to target (multi-geo documents).
2489
+ */
2490
+ async geoSearchWithCount(_point, _query, _indexName) {
2491
+ throw new require_db_error.DbError("GEO_NOT_SUPPORTED", [{
2492
+ path: "",
2493
+ message: "Geo search not supported by this adapter"
2494
+ }]);
2495
+ }
2496
+ /**
2108
2497
  * Fetches records and total count in one call.
2109
2498
  * Default: two parallel calls. Adapters may override for single-query optimization.
2110
2499
  */
@@ -2566,6 +2955,12 @@ function assertNoVersionWrites(data, versionColumn) {
2566
2955
  }
2567
2956
  //#endregion
2568
2957
  //#region src/table/db-table.ts
2958
+ /** Returns true when `value` is a plain object carrying any `$`-prefixed key (an operator object). */
2959
+ function _hasOperatorKeys(value) {
2960
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2961
+ for (const key in value) if (key.startsWith("$")) return true;
2962
+ return false;
2963
+ }
2569
2964
  /**
2570
2965
  * Generic database table abstraction driven by Atscript `@db.*` annotations.
2571
2966
  *
@@ -2673,6 +3068,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2673
3068
  };
2674
3069
  this._applyDepthCtx(ctx, depth);
2675
3070
  require_nested_writer.validateBatch(validator, items, ctx);
3071
+ await this._encryptItems(items, "write");
2676
3072
  const host = this;
2677
3073
  if (canNest) await require_nested_writer.batchInsertNestedTo(host, items, maxDepth, depth);
2678
3074
  const prepared = [];
@@ -2725,6 +3121,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2725
3121
  };
2726
3122
  this._applyDepthCtx(ctx, depth);
2727
3123
  require_nested_writer.validateBatch(validator, items, ctx);
3124
+ await this._encryptItems(items, "write");
2728
3125
  const host = this;
2729
3126
  if (canNest) await require_nested_writer.batchReplaceNestedTo(host, items, maxDepth, depth);
2730
3127
  await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver);
@@ -2786,6 +3183,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2786
3183
  this._applyDepthCtx(ctx, depth);
2787
3184
  require_nested_writer.validateBatch(validator, cloned, ctx);
2788
3185
  const originals = canNest ? cloned.map((p) => ({ ...p })) : [];
3186
+ await this._encryptItems(cloned, "patch");
2789
3187
  const host = this;
2790
3188
  if (canNest) await require_nested_writer.batchPatchNestedTo(host, cloned, maxDepth, depth);
2791
3189
  await this._integrity.validateForeignKeys(cloned, this._meta, this._fkLookupResolver, this._writeTableResolver, true);
@@ -2854,6 +3252,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2854
3252
  }
2855
3253
  async updateMany(filter, data) {
2856
3254
  this._ensureBuilt();
3255
+ this._guardMutationFilter(filter);
2857
3256
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver, true);
2858
3257
  const dataCopy = { ...data };
2859
3258
  const versionColumn = this.versionColumn;
@@ -2862,6 +3261,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2862
3261
  message: "$cas is not supported on updateMany — use bulkUpdate with per-row $cas for version-locked batch updates"
2863
3262
  }]);
2864
3263
  if (versionColumn !== void 0) assertNoVersionWrites(dataCopy, versionColumn);
3264
+ await this._encryptItems([dataCopy], "patch");
2865
3265
  const update = decomposePatch(dataCopy, this);
2866
3266
  const ops = require_ops.separateFieldOps(update);
2867
3267
  const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
@@ -2870,11 +3270,15 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2870
3270
  }
2871
3271
  async replaceMany(filter, data) {
2872
3272
  this._ensureBuilt();
3273
+ this._guardMutationFilter(filter);
2873
3274
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver);
2874
- return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite({ ...data }, this._meta, this.adapter)));
3275
+ const dataCopy = { ...data };
3276
+ await this._encryptItems([dataCopy], "write");
3277
+ return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite(dataCopy, this._meta, this.adapter)));
2875
3278
  }
2876
3279
  async deleteMany(filter) {
2877
3280
  this._ensureBuilt();
3281
+ this._guardMutationFilter(filter);
2878
3282
  if (this._integrity.needsCascade(this._cascadeResolver)) return require_nested_writer.remapDeleteFkViolation(this.tableName, () => this.adapter.withTransaction(async () => {
2879
3283
  await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
2880
3284
  return this.adapter.deleteMany(this._fieldMapper.translateFilter(filter, this._meta));
@@ -2895,6 +3299,37 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
2895
3299
  this._ensureBuilt();
2896
3300
  return this.adapter.ensureTable();
2897
3301
  }
3302
+ /** Engine-agnostic guard for user-supplied mutation filters (updateMany/deleteMany/…). */
3303
+ _guardMutationFilter(filter) {
3304
+ guardFilter(this._meta, this.adapter, filter);
3305
+ }
3306
+ /**
3307
+ * Encrypts `@db.encrypted` field values in place on (already validated)
3308
+ * write payloads — between validation and `prepareForWrite`, so adapters
3309
+ * only ever see envelope strings.
3310
+ *
3311
+ * Parent objects along an encrypted path are shallow-cloned before
3312
+ * mutation so caller-shared nested objects are never modified.
3313
+ *
3314
+ * In `patch` mode, operator objects (`$inc`, `$insert`, …) targeting an
3315
+ * encrypted field are rejected with `ENC_FIELD_PATCH_OP` — ciphertext is
3316
+ * opaque; only plain re-assignment (which re-encrypts) is allowed.
3317
+ */
3318
+ async _encryptItems(items, mode) {
3319
+ const enc = this._encryption;
3320
+ if (this._meta.encryptedFields.size === 0 || !enc) return;
3321
+ for (const item of items) for (const { path, segments, leaf } of this._encryptedPaths) {
3322
+ const parent = this._walkToLeafParent(item, segments, true);
3323
+ if (!parent) continue;
3324
+ const value = parent[leaf];
3325
+ if (value === void 0 || value === null) continue;
3326
+ if (mode === "patch" && _hasOperatorKeys(value)) throw new require_db_error.DbError("ENC_FIELD_PATCH_OP", [{
3327
+ path,
3328
+ message: `Operator patch ops are not allowed on encrypted field "${path}" — assign a plain value instead (it re-encrypts)`
3329
+ }]);
3330
+ parent[leaf] = await enc.encrypt(value);
3331
+ }
3332
+ }
2898
3333
  /**
2899
3334
  * Applies default values for fields that are missing from the payload.
2900
3335
  * Defaults handled natively by the DB engine are skipped — the field stays
@@ -3292,6 +3727,12 @@ Object.defineProperty(exports, "UniquSelect", {
3292
3727
  return UniquSelect;
3293
3728
  }
3294
3729
  });
3730
+ Object.defineProperty(exports, "assertGeoPoint", {
3731
+ enumerable: true,
3732
+ get: function() {
3733
+ return assertGeoPoint;
3734
+ }
3735
+ });
3295
3736
  Object.defineProperty(exports, "assertNoVersionWrites", {
3296
3737
  enumerable: true,
3297
3738
  get: function() {
@@ -3304,6 +3745,36 @@ Object.defineProperty(exports, "decomposePatch", {
3304
3745
  return decomposePatch;
3305
3746
  }
3306
3747
  });
3748
+ Object.defineProperty(exports, "guardAggregate", {
3749
+ enumerable: true,
3750
+ get: function() {
3751
+ return guardAggregate;
3752
+ }
3753
+ });
3754
+ Object.defineProperty(exports, "guardFilter", {
3755
+ enumerable: true,
3756
+ get: function() {
3757
+ return guardFilter;
3758
+ }
3759
+ });
3760
+ Object.defineProperty(exports, "guardQuery", {
3761
+ enumerable: true,
3762
+ get: function() {
3763
+ return guardQuery;
3764
+ }
3765
+ });
3766
+ Object.defineProperty(exports, "isGeoIndexableType", {
3767
+ enumerable: true,
3768
+ get: function() {
3769
+ return isGeoIndexableType;
3770
+ }
3771
+ });
3772
+ Object.defineProperty(exports, "isGeoPointType", {
3773
+ enumerable: true,
3774
+ get: function() {
3775
+ return isGeoPointType;
3776
+ }
3777
+ });
3307
3778
  Object.defineProperty(exports, "resolveDesignType", {
3308
3779
  enumerable: true,
3309
3780
  get: function() {