@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.
- package/dist/{db-readable-mhPp-MPv.d.cts → db-readable-BepVc21V.d.cts} +196 -3
- package/dist/{db-readable-CXdHBtF6.d.mts → db-readable-Ds01ezjj.d.mts} +196 -3
- package/dist/{db-space-BYyVZnL1.d.mts → db-space-0U_4PiNS.d.mts} +33 -4
- package/dist/{db-space-BhOc9_OO.d.cts → db-space-CWhIEC7E.d.cts} +33 -4
- package/dist/{db-view-GcbegVBD.cjs → db-view-Bw_hlrWw.cjs} +477 -6
- package/dist/{db-view-Sq4wCceR.mjs → db-view-TyzB5VFb.mjs} +442 -7
- package/dist/index.cjs +162 -5
- package/dist/index.d.cts +34 -4
- package/dist/index.d.mts +34 -4
- package/dist/index.mjs +156 -6
- package/dist/plugin.cjs +48 -0
- package/dist/plugin.mjs +48 -0
- package/dist/rel.d.cts +1 -1
- package/dist/rel.d.mts +1 -1
- package/dist/sync.cjs +2 -1
- package/dist/sync.d.cts +4 -2
- package/dist/sync.d.mts +4 -2
- package/dist/sync.mjs +2 -1
- package/dist/{validator-f43UcYiY.cjs → validator-BSk8lPH1.cjs} +15 -0
- package/dist/{validator-bLsSgi0N.mjs → validator-C7plt6Kf.mjs} +15 -0
- package/dist/validator.cjs +1 -1
- package/dist/validator.mjs +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { n as DbError } from "./db-error-BHPXOKzc.mjs";
|
|
|
2
2
|
import { resolveAlias } from "./agg.mjs";
|
|
3
3
|
import { a as batchPatchNestedTo, c as batchReplaceNestedTo, d as preValidateNestedFrom, f as validateBatch, g as findRemoteFK, h as findFKForRelation, i as batchPatchNestedFrom, l as batchReplaceNestedVia, m as remapDeleteFkViolation, n as batchInsertNestedTo, o as batchPatchNestedVia, p as enrichFkViolation, r as batchInsertNestedVia, s as batchReplaceNestedFrom, t as batchInsertNestedFrom, u as checkDepthOverflow } from "./nested-writer-DI-HeTky.mjs";
|
|
4
4
|
import { separateCas, separateFieldOps } from "./ops.mjs";
|
|
5
|
-
import { i as forceNavNonOptional, r as dbPlugin, s as getKeyProps, t as buildDbValidator } from "./validator-
|
|
5
|
+
import { i as forceNavNonOptional, r as dbPlugin, s as getKeyProps, t as buildDbValidator } from "./validator-C7plt6Kf.mjs";
|
|
6
6
|
import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
|
|
7
7
|
import { AsyncLocalStorage } from "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 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 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 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 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 DbError("INVALID_QUERY", [{
|
|
1196
|
+
path: field,
|
|
1197
|
+
message: "$geoWithin radius must be a positive number of meters"
|
|
1198
|
+
}]);
|
|
1199
|
+
if (!adapter.isGeoSearchable()) throw new 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 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 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 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 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 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 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 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 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 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
|
validateBatch(validator, items, ctx);
|
|
3071
|
+
await this._encryptItems(items, "write");
|
|
2676
3072
|
const host = this;
|
|
2677
3073
|
if (canNest) await 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
|
validateBatch(validator, items, ctx);
|
|
3124
|
+
await this._encryptItems(items, "write");
|
|
2728
3125
|
const host = this;
|
|
2729
3126
|
if (canNest) await 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
|
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 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 = 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
|
-
|
|
3275
|
+
const dataCopy = { ...data };
|
|
3276
|
+
await this._encryptItems([dataCopy], "write");
|
|
3277
|
+
return 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 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 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
|
|
@@ -3214,4 +3649,4 @@ var AtscriptDbView = class extends AtscriptDbReadable {
|
|
|
3214
3649
|
}
|
|
3215
3650
|
};
|
|
3216
3651
|
//#endregion
|
|
3217
|
-
export { ApplicationIntegrity as a, NativeIntegrity as c,
|
|
3652
|
+
export { NoopLogger as S, FieldMappingStrategy as _, ApplicationIntegrity as a, isGeoIndexableType as b, NativeIntegrity as c, assertGeoPoint as d, guardAggregate as f, DocumentFieldMapper as g, RelationalFieldMapper as h, decomposePatch as i, AtscriptDbReadable as l, guardQuery as m, AtscriptDbTable as n, BaseDbAdapter as o, guardFilter as p, assertNoVersionWrites as r, IntegrityStrategy as s, AtscriptDbView as t, resolveDesignType as u, UniquSelect as v, isGeoPointType as x, TableMetadata as y };
|