@atscript/db 0.1.141 → 0.1.143

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.
Files changed (39) hide show
  1. package/dist/agg.d.cts +1 -1
  2. package/dist/agg.d.mts +1 -1
  3. package/dist/{buckets-Bv4pah66.d.cts → buckets-Ba5lP_o9.d.cts} +358 -17
  4. package/dist/{buckets-CjL7F-hp.d.mts → buckets-BlJ4cdlr.d.mts} +358 -17
  5. package/dist/{column-diff-DiBbXyLA.d.cts → column-diff-BlGxPobU.d.cts} +1 -1
  6. package/dist/{column-diff-CfPNcP6e.cjs → column-diff-D_Kyuh0S.cjs} +906 -209
  7. package/dist/{column-diff-n-k5KY0u.d.mts → column-diff-PqA_edXA.d.mts} +1 -1
  8. package/dist/{column-diff-CmFNXV8C.mjs → column-diff-e2oHc71_.mjs} +850 -177
  9. package/dist/index.cjs +16 -10
  10. package/dist/index.d.cts +30 -5
  11. package/dist/index.d.mts +30 -5
  12. package/dist/index.mjs +6 -5
  13. package/dist/nested-writer-CnOOAehr.mjs +880 -0
  14. package/dist/nested-writer-xfQwxplL.cjs +1029 -0
  15. package/dist/{object-DSN0h9lB.d.cts → object-CtPYTIvy.d.cts} +3 -1
  16. package/dist/{object-DSN0h9lB.d.mts → object-CtPYTIvy.d.mts} +3 -1
  17. package/dist/object-Djg28csK.cjs +101 -0
  18. package/dist/object-TkiJQ-Dp.mjs +66 -0
  19. package/dist/rel.cjs +5 -2
  20. package/dist/rel.d.cts +76 -10
  21. package/dist/rel.d.mts +76 -10
  22. package/dist/rel.mjs +3 -3
  23. package/dist/{relation-helpers-B59to_dG.d.mts → relation-helpers-JTEyZzVd.d.cts} +1 -1
  24. package/dist/{relation-helpers-DQ_nRsV9.d.cts → relation-helpers-MTwzaSGA.d.mts} +1 -1
  25. package/dist/{relation-loader-CgJ8bK6X.cjs → relation-loader-CBPY6kM7.cjs} +1 -1
  26. package/dist/{relation-loader-CuhEBzFU.mjs → relation-loader-D9XuXaMv.mjs} +1 -1
  27. package/dist/sync.cjs +1 -1
  28. package/dist/sync.d.cts +2 -2
  29. package/dist/sync.d.mts +2 -2
  30. package/dist/sync.mjs +1 -1
  31. package/dist/{validator-DASnXf1j.cjs → validator-CVS-onRg.cjs} +0 -101
  32. package/dist/{validator-D8bPsXPN.mjs → validator-Clu2q_7z.mjs} +1 -66
  33. package/dist/validator.cjs +4 -3
  34. package/dist/validator.d.cts +1 -1
  35. package/dist/validator.d.mts +1 -1
  36. package/dist/validator.mjs +2 -1
  37. package/package.json +1 -1
  38. package/dist/nested-writer-BO3vhbkP.mjs +0 -661
  39. package/dist/nested-writer-DYsRxZ5f.cjs +0 -768
@@ -1,10 +1,11 @@
1
1
  import { n as CasMismatchError, r as DbError } from "./db-error-D5uilS_A.mjs";
2
2
  import { i as SUPPORTED_AGGREGATE_FNS, n as BASE_AGGREGATE_FNS } from "./aggregate-fns-CfsveE1w.mjs";
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, v as tableNameOf } from "./nested-writer-BO3vhbkP.mjs";
3
+ import { C as findRemoteFK, S as findFKForRelation, T as tableNameOf, _ as remapDeleteFkViolation, a as batchPatchNestedFrom, b as sameKey, c as batchReplaceNestedFrom, d as checkDepthOverflow, f as planNestedFromVia, g as enrichFkViolation, h as validateBatch, i as batchInsertNestedVia, l as batchReplaceNestedTo, m as preValidateNestedFrom, n as batchInsertNestedFrom, p as planPatchNestedTo, r as batchInsertNestedTo, s as batchPatchNestedVia, t as applyPatchNestedTo, u as batchReplaceNestedVia, v as pkTupleKey, x as findFKEntryForRelation, y as rowMatchesKey } from "./nested-writer-CnOOAehr.mjs";
4
4
  import { i as isJsonLeafType, n as DERIVED_INCOMPATIBLE } from "./derived-rules-0sKn4f5C.mjs";
5
- import { a as forceNavNonOptional, c as getKeyProps, d as getPath, f as isEmptyObject, i as dbPlugin, l as deletePath, m as selfOrAncestor, n as buildPatchPartial, p as isPlainObject, t as buildDbValidator, u as findAncestorInSet } from "./validator-D8bPsXPN.mjs";
5
+ import { a as isPlainObject, i as isEmptyObject, n as findAncestorInSet, o as selfOrAncestor, r as getPath, t as deletePath } from "./object-TkiJQ-Dp.mjs";
6
6
  import { resolveAlias } from "./agg.mjs";
7
7
  import { separateCas, separateFieldOps } from "./ops.mjs";
8
+ import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, n as buildPatchPartial, t as buildDbValidator } from "./validator-Clu2q_7z.mjs";
8
9
  import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
9
10
  import { BUCKET_UNITS, isAggregateExpr, isBucketExpr, isPrimitive, resolveBuckets } from "@uniqu/core";
10
11
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -197,6 +198,33 @@ function sourceIndex(type) {
197
198
  return idx;
198
199
  }
199
200
  /**
201
+ * The read seals a view column inherits from the source path it reads
202
+ * (since 0.1.143): `writeOnly` when the path or any ancestor object is
203
+ * `@db.writeOnly` (a leaf of a sealed object is sealed too); `encrypted` when
204
+ * the path itself is `@db.encrypted` (it reads the ciphertext column).
205
+ * Reads the source's live field metadata, so a source VIEW whose own fields
206
+ * inherited a seal (see `inheritViewFieldSeals`) passes it on.
207
+ * @since 0.1.143
208
+ */
209
+ function sourceFieldSeals(sourceType, logicalPath) {
210
+ const { flatMap } = sourceIndex(sourceType);
211
+ const props = sourceType.type.kind === "object" ? sourceType.type.props : void 0;
212
+ const seals = {
213
+ writeOnly: false,
214
+ encrypted: false
215
+ };
216
+ let prefix = "";
217
+ for (const segment of logicalPath.split(".")) {
218
+ const node = prefix ? flatMap.get(`${prefix}.${segment}`) : props?.get(segment);
219
+ prefix = prefix ? `${prefix}.${segment}` : segment;
220
+ const metadata = node?.metadata;
221
+ if (!metadata) break;
222
+ if (metadata.has("db.writeOnly")) seals.writeOnly = true;
223
+ if (prefix === logicalPath && metadata.has("db.encrypted")) seals.encrypted = true;
224
+ }
225
+ return seals;
226
+ }
227
+ /**
200
228
  * Resolves a LOGICAL path of a source table (a view field's chain ref, an
201
229
  * aggregate's field, a predicate operand) to where it is physically stored.
202
230
  * Internal — `AtscriptDbView.resolveRefSource` is the public entry.
@@ -2362,6 +2390,37 @@ function guardBucketUnits(adapter, buckets) {
2362
2390
  }]);
2363
2391
  }
2364
2392
  //#endregion
2393
+ //#region src/shared/index-messages.ts
2394
+ /**
2395
+ * Wording of the "index not found" errors — one source for the core, every
2396
+ * adapter and the HTTP layer, so a request naming a hidden index reads
2397
+ * exactly like one naming a nonexistent index.
2398
+ */
2399
+ /**
2400
+ * `Search index "<name>" not found`, or — without a name — the "no default
2401
+ * text index" message.
2402
+ * @since 0.1.143
2403
+ */
2404
+ function searchIndexNotFoundMessage(indexName) {
2405
+ return indexName ? `Search index "${indexName}" not found` : "No search index available";
2406
+ }
2407
+ /**
2408
+ * `Vector index "<name>" not found`, or — without a name — the "no vector
2409
+ * index" message.
2410
+ * @since 0.1.143
2411
+ */
2412
+ function vectorIndexNotFoundMessage(indexName) {
2413
+ return indexName ? `Vector index "${indexName}" not found` : "No vector index available";
2414
+ }
2415
+ /**
2416
+ * `Geo index "<name>" not found on table "<table>"`, or — without a name —
2417
+ * the "table declares no geo index" message.
2418
+ * @since 0.1.143
2419
+ */
2420
+ function geoIndexNotFoundMessage(tableName, indexName) {
2421
+ return indexName === void 0 ? `Table "${tableName}" declares no @db.index.geo — geoSearch requires a geo index` : `Geo index "${indexName}" not found on table "${tableName}"`;
2422
+ }
2423
+ //#endregion
2365
2424
  //#region src/table/db-readable.ts
2366
2425
  /**
2367
2426
  * Resolves the design type from an annotated type.
@@ -2722,6 +2781,34 @@ var AtscriptDbReadable = class {
2722
2781
  this._ensureBuilt();
2723
2782
  return this._meta.relations;
2724
2783
  }
2784
+ /**
2785
+ * The `@db.rel.FK` entry a `@db.rel.to` relation is backed by — paired
2786
+ * exactly like relation loading and nested writes pair them: by the
2787
+ * relation's alias when it has one, else by the target table. `undefined`
2788
+ * for an unknown name, a `@db.rel.from` / `@db.rel.via` relation (their key
2789
+ * lives on the other table) or a TO relation without a matching FK.
2790
+ *
2791
+ * @since 0.1.143
2792
+ */
2793
+ foreignKeyOf(relationName) {
2794
+ const relation = this.relations.get(relationName);
2795
+ if (relation?.direction !== "to") return void 0;
2796
+ return findFKEntryForRelation(relation, this._meta.foreignKeys);
2797
+ }
2798
+ /**
2799
+ * Logical paths stored as ONE JSON column (`storage: "json"` — `@db.json`
2800
+ * fields and nested objects / arrays a relational adapter serializes): the
2801
+ * engine cannot address a sub-path of such a column in a projection,
2802
+ * filter or sort, so a permission layer treats it atomically (visible whole
2803
+ * or not at all). Navigation fields excluded; empty on document adapters
2804
+ * (they store nested values natively).
2805
+ *
2806
+ * @since 0.1.143
2807
+ */
2808
+ get jsonParents() {
2809
+ this._ensureBuilt();
2810
+ return this._meta.jsonParents;
2811
+ }
2725
2812
  /** The underlying database adapter instance. */
2726
2813
  get dbAdapter() {
2727
2814
  return this.adapter;
@@ -2760,6 +2847,100 @@ var AtscriptDbReadable = class {
2760
2847
  return this._fieldMapper.reconstructRows(rows, this._meta, controls);
2761
2848
  }
2762
2849
  /**
2850
+ * Translates a read query for the adapter. A `$select` that leaves out a
2851
+ * key a `$with` relation joins on — a TO relation's foreign key, the key a
2852
+ * FROM / VIA relation is looked up by — is widened with it for the read
2853
+ * (since 0.1.143), and {@link _finishRead} strips it again once the
2854
+ * relations are loaded: the joined object never reads `null` just because
2855
+ * its key was not selected.
2856
+ */
2857
+ _translateRead(query) {
2858
+ let readQuery = query ?? {};
2859
+ const controls = readQuery.controls;
2860
+ const withRelations = controls?.$with;
2861
+ let widened = [];
2862
+ if (withRelations?.length && controls?.$select) {
2863
+ const widen = this._widenSelectForWith(controls.$select, withRelations);
2864
+ if (widen) {
2865
+ readQuery = {
2866
+ ...readQuery,
2867
+ controls: {
2868
+ ...controls,
2869
+ $select: widen.select
2870
+ }
2871
+ };
2872
+ widened = widen.added;
2873
+ }
2874
+ }
2875
+ return {
2876
+ translated: this._fieldMapper.translateQuery(readQuery, this._meta),
2877
+ controls: readQuery.controls,
2878
+ withRelations,
2879
+ widened
2880
+ };
2881
+ }
2882
+ /** Reconstructs + decrypts a read's rows, loads its `$with` relations and strips widened keys. */
2883
+ async _finishRead(results, read) {
2884
+ const rows = this._fromRead(results, read.controls);
2885
+ await this._decryptRows(rows);
2886
+ if (read.withRelations?.length) {
2887
+ await this.loadRelations(rows, read.withRelations);
2888
+ for (const key of read.widened) for (const row of rows) deletePath(row, key);
2889
+ }
2890
+ return rows;
2891
+ }
2892
+ /** `$select` plus the join keys of `withRelations` it leaves out — `undefined` when none is missing. */
2893
+ _widenSelectForWith(select, withRelations) {
2894
+ const keys = /* @__PURE__ */ new Set();
2895
+ for (const rel of withRelations) for (const key of this._joinKeysOf(rel.name)) keys.add(key);
2896
+ if (keys.size === 0) return void 0;
2897
+ if (isExclusionProjection(select)) {
2898
+ const added = [...keys].filter((key) => key in select);
2899
+ if (added.length === 0) return void 0;
2900
+ const next = { ...select };
2901
+ for (const key of added) delete next[key];
2902
+ return {
2903
+ select: next,
2904
+ added
2905
+ };
2906
+ }
2907
+ const named = new Set(Array.isArray(select) ? select.filter((key) => typeof key === "string") : Object.keys(select).filter((key) => select[key]));
2908
+ if (named.size === 0) return void 0;
2909
+ const added = [...keys].filter((key) => selfOrAncestor(key, named) === void 0);
2910
+ if (added.length === 0) return void 0;
2911
+ return {
2912
+ select: Array.isArray(select) ? [...select, ...added] : {
2913
+ ...select,
2914
+ ...Object.fromEntries(added.map((k) => [k, 1]))
2915
+ },
2916
+ added
2917
+ };
2918
+ }
2919
+ _joinKeysCache;
2920
+ /**
2921
+ * The keys of THIS table a `$with` relation joins on: a TO relation's
2922
+ * foreign-key fields; the fields a FROM relation's (or a VIA junction's)
2923
+ * foreign key references — typically the primary key. An adapter that
2924
+ * loads relations natively re-reads the rows by primary key, so it is
2925
+ * always included there. Empty for an unknown or nested (`a.b`) name.
2926
+ */
2927
+ _joinKeysOf(relName) {
2928
+ const cache = this._joinKeysCache ??= /* @__PURE__ */ new Map();
2929
+ let keys = cache.get(relName);
2930
+ if (keys === void 0) {
2931
+ keys = [];
2932
+ const relation = this._meta.relations.get(relName);
2933
+ if (relation?.direction === "to") keys = this._findFKForRelation(relation)?.localFields ?? [];
2934
+ else if (relation && this._tableResolver) {
2935
+ const remote = relation.direction === "from" ? this._tableResolver(relation.targetType()) : relation.viaType && this._tableResolver(relation.viaType());
2936
+ keys = (remote && this._findRemoteFK(remote, this.tableName, relation.direction === "from" ? relation.alias : void 0))?.targetFields ?? [];
2937
+ }
2938
+ if (relation && this.adapter.supportsNativeRelations()) keys = [...new Set([...keys, ...this.primaryKeys])];
2939
+ cache.set(relName, keys);
2940
+ }
2941
+ return keys;
2942
+ }
2943
+ /**
2763
2944
  * Pre-computed field metadata for adapter use.
2764
2945
  */
2765
2946
  get fieldDescriptors() {
@@ -2842,13 +3023,10 @@ var AtscriptDbReadable = class {
2842
3023
  async findOne(query) {
2843
3024
  this._ensureBuilt();
2844
3025
  this._guardQuery(query);
2845
- const withRelations = query.controls?.$with;
2846
- const translatedQuery = this._fieldMapper.translateQuery(query, this._meta);
2847
- const result = await this.adapter.findOne(translatedQuery);
3026
+ const read = this._translateRead(query);
3027
+ const result = await this.adapter.findOne(read.translated);
2848
3028
  if (!result) return null;
2849
- const [row] = this._fromRead([result], query.controls);
2850
- await this._decryptRows([row]);
2851
- if (withRelations?.length) await this.loadRelations([row], withRelations);
3029
+ const [row] = await this._finishRead([result], read);
2852
3030
  return row;
2853
3031
  }
2854
3032
  /**
@@ -2859,13 +3037,8 @@ var AtscriptDbReadable = class {
2859
3037
  async findMany(query) {
2860
3038
  this._ensureBuilt();
2861
3039
  this._guardQuery(query);
2862
- const withRelations = query.controls?.$with;
2863
- const translatedQuery = this._fieldMapper.translateQuery(query, this._meta);
2864
- const results = await this.adapter.findMany(translatedQuery);
2865
- const rows = this._fromRead(results, query.controls);
2866
- await this._decryptRows(rows);
2867
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
2868
- return rows;
3040
+ const read = this._translateRead(query);
3041
+ return await this._finishRead(await this.adapter.findMany(read.translated), read);
2869
3042
  }
2870
3043
  /**
2871
3044
  * Counts records matching the query.
@@ -2885,14 +3058,10 @@ var AtscriptDbReadable = class {
2885
3058
  async findManyWithCount(query) {
2886
3059
  this._ensureBuilt();
2887
3060
  this._guardQuery(query);
2888
- const withRelations = query.controls?.$with;
2889
- const translated = this._fieldMapper.translateQuery(query, this._meta);
2890
- const result = await this.adapter.findManyWithCount(translated);
2891
- const rows = this._fromRead(result.data, query.controls);
2892
- await this._decryptRows(rows);
2893
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3061
+ const read = this._translateRead(query);
3062
+ const result = await this.adapter.findManyWithCount(read.translated);
2894
3063
  return {
2895
- data: rows,
3064
+ data: await this._finishRead(result.data, read),
2896
3065
  count: result.count
2897
3066
  };
2898
3067
  }
@@ -3003,13 +3172,9 @@ var AtscriptDbReadable = class {
3003
3172
  this._ensureBuilt();
3004
3173
  this._ensureSearchable();
3005
3174
  this._guardQuery(query);
3006
- const withRelations = query.controls?.$with;
3007
- const translated = this._fieldMapper.translateQuery(query, this._meta);
3008
- const results = await this.adapter.search(text, translated, indexName);
3009
- const rows = this._fromRead(results, query.controls);
3010
- await this._decryptRows(rows);
3011
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3012
- return rows;
3175
+ const read = this._translateRead(query);
3176
+ const results = await this.adapter.search(text, read.translated, indexName);
3177
+ return await this._finishRead(results, read);
3013
3178
  }
3014
3179
  /**
3015
3180
  * Full-text search with count for paginated search results.
@@ -3018,14 +3183,10 @@ var AtscriptDbReadable = class {
3018
3183
  this._ensureBuilt();
3019
3184
  this._ensureSearchable();
3020
3185
  this._guardQuery(query);
3021
- const withRelations = query.controls?.$with;
3022
- const translated = this._fieldMapper.translateQuery(query, this._meta);
3023
- const result = await this.adapter.searchWithCount(text, translated, indexName);
3024
- const rows = this._fromRead(result.data, query.controls);
3025
- await this._decryptRows(rows);
3026
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3186
+ const read = this._translateRead(query);
3187
+ const result = await this.adapter.searchWithCount(text, read.translated, indexName);
3027
3188
  return {
3028
- data: rows,
3189
+ data: await this._finishRead(result.data, read),
3029
3190
  count: result.count
3030
3191
  };
3031
3192
  }
@@ -3044,13 +3205,9 @@ var AtscriptDbReadable = class {
3044
3205
  const { vector, query, indexName } = this._resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery);
3045
3206
  this._ensureBuilt();
3046
3207
  this._guardQuery(query);
3047
- const withRelations = (query?.controls)?.$with;
3048
- const translated = this._fieldMapper.translateQuery(query || {}, this._meta);
3049
- const results = await this.adapter.vectorSearch(vector, translated, indexName);
3050
- const rows = this._fromRead(results, query?.controls);
3051
- await this._decryptRows(rows);
3052
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3053
- return rows;
3208
+ const read = this._translateRead(query);
3209
+ const results = await this.adapter.vectorSearch(vector, read.translated, indexName);
3210
+ return await this._finishRead(results, read);
3054
3211
  }
3055
3212
  /**
3056
3213
  * Vector similarity search with count for paginated results.
@@ -3063,14 +3220,10 @@ var AtscriptDbReadable = class {
3063
3220
  const { vector, query, indexName } = this._resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery);
3064
3221
  this._ensureBuilt();
3065
3222
  this._guardQuery(query);
3066
- const withRelations = (query?.controls)?.$with;
3067
- const translated = this._fieldMapper.translateQuery(query || {}, this._meta);
3068
- const result = await this.adapter.vectorSearchWithCount(vector, translated, indexName);
3069
- const rows = this._fromRead(result.data, query?.controls);
3070
- await this._decryptRows(rows);
3071
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3223
+ const read = this._translateRead(query);
3224
+ const result = await this.adapter.vectorSearchWithCount(vector, read.translated, indexName);
3072
3225
  return {
3073
- data: rows,
3226
+ data: await this._finishRead(result.data, read),
3074
3227
  count: result.count
3075
3228
  };
3076
3229
  }
@@ -3103,12 +3256,9 @@ var AtscriptDbReadable = class {
3103
3256
  */
3104
3257
  async geoSearch(pointOrIndex, maybePointOrQuery, maybeQuery) {
3105
3258
  const { point, query, indexName } = this._resolveGeoSearchArgs(pointOrIndex, maybePointOrQuery, maybeQuery);
3106
- const { translated, withRelations } = this._prepareGeoSearch(point, query, indexName);
3107
- const results = await this.adapter.geoSearch(point, translated, indexName);
3108
- const rows = this._fromRead(results, query?.controls);
3109
- await this._decryptRows(rows);
3110
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3111
- return rows;
3259
+ const read = this._prepareGeoSearch(point, query, indexName);
3260
+ const results = await this.adapter.geoSearch(point, read.translated, indexName);
3261
+ return await this._finishRead(results, read);
3112
3262
  }
3113
3263
  /**
3114
3264
  * Distance-ranked geospatial search with count for paginated results.
@@ -3119,13 +3269,10 @@ var AtscriptDbReadable = class {
3119
3269
  */
3120
3270
  async geoSearchWithCount(pointOrIndex, maybePointOrQuery, maybeQuery) {
3121
3271
  const { point, query, indexName } = this._resolveGeoSearchArgs(pointOrIndex, maybePointOrQuery, maybeQuery);
3122
- const { translated, withRelations } = this._prepareGeoSearch(point, query, indexName);
3123
- const result = await this.adapter.geoSearchWithCount(point, translated, indexName);
3124
- const rows = this._fromRead(result.data, query?.controls);
3125
- await this._decryptRows(rows);
3126
- if (withRelations?.length) await this.loadRelations(rows, withRelations);
3272
+ const read = this._prepareGeoSearch(point, query, indexName);
3273
+ const result = await this.adapter.geoSearchWithCount(point, read.translated, indexName);
3127
3274
  return {
3128
- data: rows,
3275
+ data: await this._finishRead(result.data, read),
3129
3276
  count: result.count
3130
3277
  };
3131
3278
  }
@@ -3145,18 +3292,18 @@ var AtscriptDbReadable = class {
3145
3292
  /** Shared geoSearch validation + query translation. */
3146
3293
  _prepareGeoSearch(point, query, indexName) {
3147
3294
  this._ensureBuilt();
3148
- if (!this.adapter.isGeoSearchable()) throw new DbError("GEO_NOT_SUPPORTED", [{
3149
- path: "",
3150
- message: `Geo search is not supported by the adapter behind table "${this.tableName}"`
3151
- }]);
3152
3295
  const geoIndexes = [...this._meta.indexes.values()].filter((index) => index.type === "geo");
3153
3296
  if (geoIndexes.length === 0) throw new DbError("GEO_INDEX_MISSING", [{
3154
3297
  path: "",
3155
- message: `Table "${this.tableName}" declares no @db.index.geo — geoSearch requires a geo index`
3298
+ message: geoIndexNotFoundMessage(this.tableName)
3156
3299
  }]);
3157
3300
  if (indexName !== void 0 && !geoIndexes.some((index) => index.name === indexName)) throw new DbError("GEO_INDEX_MISSING", [{
3158
3301
  path: indexName,
3159
- message: `Geo index "${indexName}" not found on table "${this.tableName}"`
3302
+ message: geoIndexNotFoundMessage(this.tableName, indexName)
3303
+ }]);
3304
+ if (!this.adapter.isGeoSearchable()) throw new DbError("GEO_NOT_SUPPORTED", [{
3305
+ path: "",
3306
+ message: `Geo search is not supported by the adapter behind table "${this.tableName}"`
3160
3307
  }]);
3161
3308
  assertGeoPoint(point, "$center");
3162
3309
  const controls = query?.controls ?? {};
@@ -3172,17 +3319,17 @@ var AtscriptDbReadable = class {
3172
3319
  }]);
3173
3320
  }
3174
3321
  this._guardQuery(query);
3175
- const withRelations = (query?.controls)?.$with;
3176
- return {
3177
- translated: this._fieldMapper.translateQuery(query || {}, this._meta),
3178
- withRelations
3179
- };
3322
+ return this._translateRead(query);
3180
3323
  }
3181
3324
  /**
3182
3325
  * Finds a single record by any type-compatible identifier — primary key
3183
3326
  * or single-field unique index.
3184
3327
  * The return type excludes nav props unless `$with` is provided in controls.
3185
3328
  *
3329
+ * The id addresses exactly ONE row, primary key first (since 0.1.143) —
3330
+ * see {@link resolveRowFilter}: when a scalar id equals one row's primary
3331
+ * key and another row's unique key, the primary-key row is returned.
3332
+ *
3186
3333
  * ```typescript
3187
3334
  * // Without relations — nav props stripped from result
3188
3335
  * const user = await table.findById('123')
@@ -3192,61 +3339,194 @@ var AtscriptDbReadable = class {
3192
3339
  * ```
3193
3340
  */
3194
3341
  async findById(id, query) {
3342
+ return this.findOneByRow(id, { controls: query?.controls });
3343
+ }
3344
+ /**
3345
+ * Reads the ONE row an id addresses — resolved exactly like
3346
+ * {@link resolveRowFilter} (primary key first, `opts.scope` /
3347
+ * `opts.isFieldVisible` as there) — with `opts.controls` applied, in one
3348
+ * step: the identifications are probed in order with the caller's
3349
+ * controls and the first row found wins, so no pin-then-reread. The row
3350
+ * must also match `opts.scope` (an out-of-scope row answers `null` like a
3351
+ * missing one).
3352
+ *
3353
+ * @since 0.1.143
3354
+ */
3355
+ async findOneByRow(id, opts) {
3195
3356
  this._ensureBuilt();
3196
- const filter = this._resolveIdFilter(id);
3197
- if (!filter) return null;
3198
- return await this.findOne({
3199
- filter,
3200
- controls: query?.controls || {}
3201
- });
3357
+ const controls = opts?.controls ?? {};
3358
+ for (const candidate of this._idCandidates(id, opts)) {
3359
+ const row = await this.findOne({
3360
+ filter: this._andScope(candidate, opts?.scope),
3361
+ controls
3362
+ });
3363
+ if (row) return row;
3364
+ }
3365
+ return null;
3202
3366
  }
3203
3367
  /**
3204
3368
  * Resolve an id value (scalar or object) into a {@link FilterExpr} using the
3205
- * same identification resolution as {@link findById}. Public so callers can
3369
+ * same identifications as {@link findById}. Public so callers can
3206
3370
  * AND-combine the id-filter with a row-level read overlay before issuing
3207
3371
  * `findOne` (avoiding the existence leak that `findById` would cause).
3208
3372
  * `opts.isFieldVisible` (since 0.1.134) drops unique indexes over hidden
3209
3373
  * fields — see {@link identificationsVisibleTo}.
3374
+ *
3375
+ * The result is a plain `$or` over every identification the id is
3376
+ * type-compatible with (a scalar can equal one row's primary key AND
3377
+ * another row's unique key), so it may match more than one row. Code that
3378
+ * must address exactly one row — writes, pre-images, `/one` reads — uses
3379
+ * {@link resolveRowFilter} instead.
3210
3380
  */
3211
3381
  resolveIdFilter(id, opts) {
3212
3382
  return this._resolveIdFilter(id, opts);
3213
3383
  }
3214
3384
  /**
3215
- * Resolve an id value into a filter expression.
3385
+ * Resolve an id value (scalar or object) into a filter that matches exactly
3386
+ * ONE row, deterministically and primary key first (since 0.1.143):
3387
+ *
3388
+ * - an id that yields a single identification (e.g. a numeric PK with no
3389
+ * type-compatible unique key) resolves to it without a read;
3390
+ * - an object id carrying the complete primary key resolves by the primary
3391
+ * key alone — exactly like a write payload is identified;
3392
+ * - otherwise the identifications are tried in order (primary key first,
3393
+ * then each unique index): the first one that matches a row wins and the
3394
+ * result is that row's exact primary-key filter;
3395
+ * - when none matches, the first identification is returned (it matches
3396
+ * nothing, so callers answer "not found" as usual).
3397
+ *
3398
+ * `null` when the id resolves to no identification at all. Every write
3399
+ * (`deleteOne`, the guards' `current()`) and `findById` go through this
3400
+ * resolution; call it inside the write's transaction when the answer must
3401
+ * stay pinned. `opts.isFieldVisible` as in {@link resolveIdFilter}.
3402
+ *
3403
+ * `opts.scope` (a row-level overlay) restricts which rows count as matches
3404
+ * while the identifications are tried — a row outside it never shadows one
3405
+ * inside it, so the answer is the same as if that row did not exist. AND
3406
+ * the scope onto the result to exclude an out-of-scope row the id names
3407
+ * unambiguously. See {@link TRowResolveOptions}.
3408
+ */
3409
+ async resolveRowFilter(id, opts) {
3410
+ this._ensureBuilt();
3411
+ return this._pinIdCandidates(this._idCandidates(id, opts), opts?.scope);
3412
+ }
3413
+ /**
3414
+ * Resolve an id value into a filter expression (the `$or` of
3415
+ * {@link _idCandidates}; a single candidate is returned as-is).
3416
+ */
3417
+ _resolveIdFilter(id, opts) {
3418
+ const candidates = this._idCandidates(id, opts, false);
3419
+ if (candidates.length === 0) return null;
3420
+ if (candidates.length === 1) return candidates[0];
3421
+ return { $or: candidates };
3422
+ }
3423
+ /**
3424
+ * The ordered identification filters an id value can resolve through —
3425
+ * primary key first, then each unique index.
3216
3426
  *
3217
3427
  * When `preferredId` differs from the PK, scalar ids resolve only against
3218
3428
  * the preferred field (deterministic addressing). Otherwise scalars try PK
3219
3429
  * + every single-field unique index; objects try PK + compound unique
3220
3430
  * indexes. With `opts.isFieldVisible`, only the identifications
3221
- * {@link identificationsVisibleTo} keeps are tried.
3431
+ * {@link identificationsVisibleTo} keeps are tried. With `pkWins` (the
3432
+ * default), an object id carrying the complete primary key yields the
3433
+ * primary-key filter alone.
3222
3434
  */
3223
- _resolveIdFilter(id, opts) {
3435
+ _idCandidates(id, opts, pkWins = true) {
3224
3436
  const pkFields = this.primaryKeys;
3225
3437
  const preferredFields = this.preferredId;
3226
3438
  const isExplicitPreferred = preferredFields.length !== pkFields.length || preferredFields.some((f, i) => f !== pkFields[i]);
3227
3439
  const isScalar = id === null || typeof id !== "object";
3228
- if (isScalar && isExplicitPreferred && preferredFields.length === 1) return this._tryFieldFilter(preferredFields[0], id);
3440
+ if (isScalar && isExplicitPreferred && preferredFields.length === 1) {
3441
+ const filter = this._tryFieldFilter(preferredFields[0], id);
3442
+ return filter ? [filter] : [];
3443
+ }
3444
+ const idObj = isScalar ? null : id;
3445
+ if (idObj && pkWins && pkFields.length > 0) {
3446
+ const pkFilter = this._tryCompoundFilter(pkFields, idObj);
3447
+ if (pkFilter) return [pkFilter];
3448
+ }
3229
3449
  const tryScalarOrField = (field) => {
3230
- const value = isScalar ? id : id[field];
3450
+ const value = isScalar ? id : idObj[field];
3231
3451
  return value === void 0 ? null : this._tryFieldFilter(field, value);
3232
3452
  };
3233
- const orFilters = [];
3234
- const idObj = isScalar ? null : id;
3453
+ const candidates = [];
3235
3454
  const identifications = this.identificationsVisibleTo(opts?.isFieldVisible);
3236
3455
  for (const ident of identifications) {
3237
3456
  if (ident.fields.length !== 1) continue;
3238
3457
  const filter = tryScalarOrField(ident.fields[0]);
3239
- if (filter) orFilters.push(filter);
3458
+ if (filter) candidates.push(filter);
3240
3459
  }
3241
3460
  if (idObj) for (const ident of identifications) {
3242
3461
  if (ident.fields.length < 2) continue;
3243
- if (ident.source !== "primaryKey" && orFilters.length > 0) break;
3462
+ if (ident.source !== "primaryKey" && candidates.length > 0) break;
3244
3463
  const filter = this._tryCompoundFilter(ident.fields, idObj);
3245
- if (filter) orFilters.push(filter);
3464
+ if (filter) candidates.push(filter);
3465
+ }
3466
+ return candidates;
3467
+ }
3468
+ /**
3469
+ * Picks the one row a list of identification candidates addresses — see
3470
+ * {@link resolveRowFilter}. A lone candidate needs no read. With a
3471
+ * non-empty `scope`, only rows matching it count as matches.
3472
+ */
3473
+ async _pinIdCandidates(candidates, scope) {
3474
+ if (candidates.length === 0) return null;
3475
+ if (candidates.length === 1) return candidates[0];
3476
+ const pkFields = this.primaryKeys;
3477
+ const select = new Set(pkFields);
3478
+ for (const candidate of candidates) for (const field in candidate) select.add(field);
3479
+ const rows = await this.findMany({
3480
+ filter: this._andScope({ $or: candidates }, scope),
3481
+ controls: { $select: [...select] }
3482
+ });
3483
+ if (rows.length === 0) return candidates[0];
3484
+ for (const candidate of candidates) {
3485
+ const row = rows.find((r) => rowMatchesKey(r, candidate));
3486
+ if (row) return this._pkFilterFrom(row) ?? candidate;
3487
+ }
3488
+ return this._probeIdCandidates(candidates, scope);
3489
+ }
3490
+ /** Sequential fallback of {@link _pinIdCandidates}: first candidate matching a row wins. */
3491
+ async _probeIdCandidates(candidates, scope) {
3492
+ for (const candidate of candidates) {
3493
+ const pinned = await this._readPkFilter(candidate, scope);
3494
+ if (pinned) return pinned;
3246
3495
  }
3247
- if (orFilters.length === 0) return null;
3248
- if (orFilters.length === 1) return orFilters[0];
3249
- return { $or: orFilters };
3496
+ return candidates[0];
3497
+ }
3498
+ /**
3499
+ * The exact primary-key filter of `row` (values prepared like ids) — `null`
3500
+ * when the table has no primary key or `row` lacks a key field.
3501
+ */
3502
+ _pkFilterFrom(row) {
3503
+ const pkFields = this.primaryKeys;
3504
+ if (pkFields.length === 0) return null;
3505
+ const filter = {};
3506
+ for (const field of pkFields) {
3507
+ const value = row[field];
3508
+ if (value === void 0) return null;
3509
+ const fieldType = this.flatMap.get(field);
3510
+ filter[field] = fieldType ? this.adapter.prepareId(value, fieldType) : value;
3511
+ }
3512
+ return filter;
3513
+ }
3514
+ /**
3515
+ * Reads the row `filter` (AND `scope`) matches and returns its exact
3516
+ * primary-key filter (`filter` itself on a table without one); `undefined`
3517
+ * when no row matches.
3518
+ */
3519
+ async _readPkFilter(filter, scope) {
3520
+ const pkFields = this.primaryKeys;
3521
+ const row = await this.findOne({
3522
+ filter: this._andScope(filter, scope),
3523
+ controls: pkFields.length > 0 ? { $select: [...pkFields] } : {}
3524
+ });
3525
+ return row ? this._pkFilterFrom(row) ?? filter : void 0;
3526
+ }
3527
+ /** `filter` AND a row scope — `filter` itself when the scope is absent or empty. */
3528
+ _andScope(filter, scope) {
3529
+ return scope === void 0 || isEmptyObject(scope) ? filter : { $and: [filter, scope] };
3250
3530
  }
3251
3531
  /** Build a single-key filter from `idObj` over `fields`, or null if any field is missing/incompatible. */
3252
3532
  _tryCompoundFilter(fields, idObj) {
@@ -3281,7 +3561,7 @@ var AtscriptDbReadable = class {
3281
3561
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
3282
3562
  */
3283
3563
  async loadRelations(rows, withRelations) {
3284
- const { loadRelationsImpl } = await import("./relation-loader-CuhEBzFU.mjs").then((n) => n.n);
3564
+ const { loadRelationsImpl } = await import("./relation-loader-D9XuXaMv.mjs").then((n) => n.n);
3285
3565
  return loadRelationsImpl(rows, withRelations, this);
3286
3566
  }
3287
3567
  /**
@@ -3485,6 +3765,16 @@ var BaseDbAdapter = class {
3485
3765
  return findTxContext(this._transactionOwner())?.state;
3486
3766
  }
3487
3767
  /**
3768
+ * `true` when the current async context runs inside a REAL transaction of
3769
+ * this adapter (its owner) — one a throw rolls back (since 0.1.143).
3770
+ * `false` outside any transaction and inside a pass-through
3771
+ * `withTransaction` (adapters without transaction primitives such as the
3772
+ * in-memory one, a standalone MongoDB topology).
3773
+ */
3774
+ isInTransaction() {
3775
+ return this._getTransactionState() !== void 0;
3776
+ }
3777
+ /**
3488
3778
  * Runs `fn` inside the transaction ALS context with the given state.
3489
3779
  * Adapters that override `withTransaction` (e.g., to use MongoDB's
3490
3780
  * `session.withTransaction()` Convenient API) use this to set up the
@@ -3763,6 +4053,25 @@ var BaseDbAdapter = class {
3763
4053
  getSearchIndexes() {
3764
4054
  return [];
3765
4055
  }
4056
+ _physicalToLogical;
4057
+ /**
4058
+ * The LOGICAL field paths an index reads (its `fields` carry physical
4059
+ * names) — what adapters report as {@link TSearchIndexInfo.fields}. A
4060
+ * derived column never shadows the regular field sharing its physical name.
4061
+ * @since 0.1.143
4062
+ */
4063
+ _indexLogicalPaths(index) {
4064
+ let logical = this._physicalToLogical;
4065
+ if (!logical) {
4066
+ logical = /* @__PURE__ */ new Map();
4067
+ for (const fd of this._table.fieldDescriptors) {
4068
+ if (fd.ignored || fd.derived && logical.has(fd.physicalName)) continue;
4069
+ logical.set(fd.physicalName, fd.path);
4070
+ }
4071
+ this._physicalToLogical = logical;
4072
+ }
4073
+ return index.fields.map((field) => logical.get(field.name) ?? field.name);
4074
+ }
3766
4075
  /**
3767
4076
  * Whether this adapter can run TEXT search — `search()`, `searchWithCount()`
3768
4077
  * and the grouped `$search` path all gate on it. Vector capability is a
@@ -3844,7 +4153,7 @@ var BaseDbAdapter = class {
3844
4153
  const column = (indexName ? geoIndexes.find((candidate) => candidate.name === indexName) : geoIndexes[0])?.fields[0]?.name;
3845
4154
  if (!column) throw new DbError("GEO_INDEX_MISSING", [{
3846
4155
  path: indexName ?? "",
3847
- message: `No geo index${indexName ? ` "${indexName}"` : ""} on "${this._table.tableName}"`
4156
+ message: geoIndexNotFoundMessage(this._table.tableName, indexName)
3848
4157
  }]);
3849
4158
  return column;
3850
4159
  }
@@ -4466,30 +4775,99 @@ function _shallowPrunedClone(source) {
4466
4775
  }
4467
4776
  return out;
4468
4777
  }
4778
+ /** Same keys, same key values (across driver representations). */
4779
+ function sameFilter(a, b) {
4780
+ const aKeys = Object.keys(a);
4781
+ return aKeys.length === Object.keys(b).length && aKeys.every((key) => sameKey(a[key], b[key]));
4782
+ }
4469
4783
  /**
4470
- * {@link TDbWriteGuardContext} handed to a write guard: a sparse per-index
4471
- * cache of pre-image reads, allocated only when `current(i)` is first used.
4784
+ * {@link TDbWriteGuardContext} handed to a write guard: sparse per-index
4785
+ * caches of record filters and pre-image reads, allocated on first use.
4786
+ * The write reuses a read for its own target pin ({@link readFor}).
4472
4787
  */
4473
4788
  var WriteGuardContext = class {
4474
4789
  action;
4475
4790
  rows;
4476
4791
  expectedVersions;
4477
4792
  _table;
4478
- _pending;
4479
- constructor(action, rows, expectedVersions, _table) {
4793
+ _opts;
4794
+ _filters;
4795
+ _reads;
4796
+ constructor(action, rows, expectedVersions, _table, _opts) {
4480
4797
  this.action = action;
4481
4798
  this.rows = rows;
4482
4799
  this.expectedVersions = expectedVersions;
4483
4800
  this._table = _table;
4801
+ this._opts = _opts;
4802
+ }
4803
+ filterFor(i) {
4804
+ const filters = this._filters ??= [];
4805
+ let filter = filters[i];
4806
+ if (filter === void 0) {
4807
+ filter = this._table._recordFilterOrNull(this.rows[i], this._opts);
4808
+ filters[i] = filter;
4809
+ }
4810
+ return filter;
4484
4811
  }
4485
4812
  current(i) {
4486
- const cache = this._pending ??= [];
4487
- let pending = cache[i];
4488
- if (!pending) {
4489
- pending = this._table._readPreImage(this.rows[i]);
4490
- cache[i] = pending;
4813
+ const reads = this._reads ??= [];
4814
+ let read = reads[i];
4815
+ if (!read) {
4816
+ const filter = this.filterFor(i);
4817
+ read = filter ? this._table.findOne({
4818
+ filter,
4819
+ controls: {}
4820
+ }) : Promise.resolve(null);
4821
+ reads[i] = read;
4822
+ }
4823
+ return read;
4824
+ }
4825
+ currentAll() {
4826
+ const reads = this._reads ??= [];
4827
+ const pending = [];
4828
+ for (let i = 0; i < this.rows.length; i++) {
4829
+ if (reads[i]) continue;
4830
+ if (this.filterFor(i)) pending.push(i);
4831
+ else reads[i] = Promise.resolve(null);
4832
+ }
4833
+ if (pending.length > 0) {
4834
+ const found = this._readMany(pending.map((i) => this.filterFor(i)));
4835
+ pending.forEach((i, k) => {
4836
+ reads[i] = found.then((rows) => rows[k]);
4837
+ });
4491
4838
  }
4492
- return pending;
4839
+ return Promise.all(this.rows.map((_, i) => reads[i]));
4840
+ }
4841
+ /**
4842
+ * One `findMany` for the `filters` (full rows, like `current(i)`), matched
4843
+ * back in memory; a filter the store matched differently (e.g. a
4844
+ * case-insensitive collation) is re-read on its own.
4845
+ */
4846
+ async _readMany(filters) {
4847
+ const table = this._table;
4848
+ const rows = await table.findMany({
4849
+ filter: filters.length === 1 ? filters[0] : { $or: filters },
4850
+ controls: {}
4851
+ });
4852
+ const used = /* @__PURE__ */ new Set();
4853
+ const out = filters.map((filter) => {
4854
+ const row = rows.find((r) => rowMatchesKey(r, filter));
4855
+ if (row) used.add(row);
4856
+ return row ?? null;
4857
+ });
4858
+ if (used.size < rows.length) {
4859
+ for (let k = 0; k < filters.length; k++) if (out[k] === null) out[k] = await table.findOne({
4860
+ filter: filters[k],
4861
+ controls: {}
4862
+ });
4863
+ }
4864
+ return out;
4865
+ }
4866
+ /** The pre-image `current(i)` read by exactly `filter`, if the guard asked for it. */
4867
+ readFor(i, filter) {
4868
+ const read = this._reads?.[i];
4869
+ const readBy = this._filters?.[i];
4870
+ return read && readBy && sameFilter(readBy, filter) ? read : void 0;
4493
4871
  }
4494
4872
  };
4495
4873
  /** {@link TDbRemoveGuardContext} handed to a delete guard (one memoised pre-image read). */
@@ -4511,6 +4889,23 @@ var RemoveGuardContext = class {
4511
4889
  return this._pending;
4512
4890
  }
4513
4891
  };
4892
+ /** Number of `true` entries. */
4893
+ function countTrue(flags) {
4894
+ let n = 0;
4895
+ for (const flag of flags) if (flag) n++;
4896
+ return n;
4897
+ }
4898
+ /**
4899
+ * A write item matched nothing although its row was relied upon — a replace
4900
+ * whose nested TO rows were already written, or a nested child re-parented
4901
+ * since the plan. The row changed meanwhile; thrown to roll back.
4902
+ */
4903
+ function concurrentChange() {
4904
+ return new DbError("CONFLICT", [{
4905
+ path: "",
4906
+ message: "The record changed during the write — nothing was written, retry"
4907
+ }]);
4908
+ }
4514
4909
  /** Upper bound of keys per `touchMany` UPDATE statement (parameter-count safety). */
4515
4910
  const TOUCH_MANY_CHUNK = 500;
4516
4911
  /** `touchMany` input rejection — always `INVALID_QUERY`, path names the key. */
@@ -4581,11 +4976,13 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4581
4976
  * Recursive up to `maxDepth` (default 3).
4582
4977
  *
4583
4978
  * `opts.guard` (since 0.1.128) runs once inside the transaction, after
4584
- * defaults + validation, with the prepared rows — see {@link TWriteOptions}.
4979
+ * defaults + validation, with the prepared rows; `opts.check` (since
4980
+ * 0.1.143) runs once after every phase with the inserted rows' primary-key
4981
+ * filters — see {@link TWriteOptions}.
4585
4982
  */
4586
4983
  async insertMany(payloads, opts) {
4587
4984
  this._ensureBuilt();
4588
- const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
4985
+ const { _depth, _action, maxDepth: userMax, guard, check } = opts ?? {};
4589
4986
  const maxDepth = userMax ?? 3;
4590
4987
  const depth = _depth ?? 0;
4591
4988
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
@@ -4602,7 +4999,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4602
4999
  this._applyDepthCtx(ctx, depth);
4603
5000
  validateBatch(validator, items, ctx);
4604
5001
  if (guard) {
4605
- await guard(new WriteGuardContext(_action ?? "insertMany", items, Array.from({ length: items.length }), this));
5002
+ await guard(new WriteGuardContext(_action ?? "insertMany", items, Array.from({ length: items.length }), this, opts));
4606
5003
  validateBatch(validator, items, ctx);
4607
5004
  }
4608
5005
  await this._encryptItems(items, "write");
@@ -4618,6 +5015,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4618
5015
  const result = await this.adapter.insertMany(prepared);
4619
5016
  if (canNest) await batchInsertNestedFrom(host, originals, result.insertedIds, maxDepth, depth);
4620
5017
  if (canNest) await batchInsertNestedVia(host, originals, result.insertedIds, maxDepth, depth);
5018
+ if (check) await this._runWriteCheck(check, _action ?? "insertMany", this._insertedPkFilters(items, prepared, result.insertedIds));
4621
5019
  return result;
4622
5020
  }));
4623
5021
  }
@@ -4640,11 +5038,13 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4640
5038
  * re-create junction rows. Fully recursive up to `maxDepth` (default 3).
4641
5039
  *
4642
5040
  * `opts.guard` (since 0.1.128) runs once inside the transaction, after
4643
- * `$cas` extraction, defaults + validation — see {@link TWriteOptions}.
5041
+ * `$cas` extraction, defaults + validation; `opts.check` (since 0.1.143)
5042
+ * after every phase — see {@link TWriteOptions}. The nested phases run only
5043
+ * for the rows the main replace matched.
4644
5044
  */
4645
5045
  async bulkReplace(payloads, opts) {
4646
5046
  this._ensureBuilt();
4647
- const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
5047
+ const { _depth, _action, _ownedBy, maxDepth: userMax, guard, check } = opts ?? {};
4648
5048
  const maxDepth = userMax ?? 3;
4649
5049
  const depth = _depth ?? 0;
4650
5050
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
@@ -4666,31 +5066,40 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4666
5066
  };
4667
5067
  this._applyDepthCtx(ctx, depth);
4668
5068
  validateBatch(validator, items, ctx);
5069
+ let guardCtx;
4669
5070
  if (guard) {
4670
- await guard(new WriteGuardContext(_action ?? "replaceMany", items, expectedVersions, this));
5071
+ guardCtx = new WriteGuardContext(_action ?? "replaceMany", items, expectedVersions, this, opts);
5072
+ await guard(guardCtx);
4671
5073
  validateBatch(validator, items, ctx);
4672
5074
  }
4673
5075
  await this._encryptItems(items, "write");
4674
5076
  const host = this;
4675
- if (canNest) await batchReplaceNestedTo(host, items, maxDepth, depth);
5077
+ const rowFilters = this._rowFilters(items, opts);
5078
+ const nestedPlan = canNest ? await planNestedFromVia(host, originals, "replace") : void 0;
5079
+ const nestedTo = canNest ? items.map((item) => this._carriesNestedTo(item)) : [];
5080
+ const targets = await this._pinTargets(rowFilters, (i) => nestedTo[i] || check !== void 0 && !this._isPkFilter(rowFilters[i]), guardCtx);
5081
+ const toApplied = this._gateNestedTo(items, nestedTo, targets, expectedVersions);
5082
+ if (toApplied.some(Boolean)) await batchReplaceNestedTo(host, items, maxDepth, depth);
4676
5083
  await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver);
4677
5084
  if (canNest) await preValidateNestedFrom(host, originals);
4678
- let matchedCount = 0;
4679
5085
  let modifiedCount = 0;
5086
+ const matched = [];
4680
5087
  for (let i = 0; i < items.length; i++) {
4681
5088
  const data = items[i];
4682
5089
  for (const navField of this._meta.navFields) delete data[navField];
4683
5090
  if (versionColumn !== void 0) assertNoVersionWrites(data, versionColumn);
4684
- const filter = this._extractRecordFilter(data, opts);
4685
5091
  const prepared = this._fieldMapper.prepareForWrite(data, this._meta, this.adapter);
4686
- const result = await this.adapter.replaceOne(this._fieldMapper.translateFilter(filter, this._meta), prepared, expectedVersions[i]);
4687
- matchedCount += result.matchedCount;
5092
+ const result = await this.adapter.replaceOne(this._fieldMapper.translateFilter(rowFilters[i], this._meta), prepared, expectedVersions[i]);
4688
5093
  modifiedCount += result.modifiedCount;
5094
+ matched[i] = result.matchedCount > 0;
5095
+ if (!matched[i] && (toApplied[i] || _ownedBy?.strict?.[i])) throw concurrentChange();
4689
5096
  }
4690
- if (canNest) await batchReplaceNestedFrom(host, originals, maxDepth, depth);
4691
- if (canNest) await batchReplaceNestedVia(host, originals, maxDepth, depth);
5097
+ const matchedOriginals = canNest ? originals.filter((_, i) => matched[i]) : [];
5098
+ if (matchedOriginals.length > 0) await batchReplaceNestedFrom(host, matchedOriginals, maxDepth, depth, nestedPlan);
5099
+ if (matchedOriginals.length > 0) await batchReplaceNestedVia(host, matchedOriginals, maxDepth, depth);
5100
+ if (check) await this._runWriteCheck(check, _action ?? "replaceMany", this._writtenPkFilters(rowFilters, targets, matched));
4692
5101
  return {
4693
- matchedCount,
5102
+ matchedCount: countTrue(matched),
4694
5103
  modifiedCount
4695
5104
  };
4696
5105
  }));
@@ -4714,11 +5123,14 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4714
5123
  *
4715
5124
  * `opts.guard` (since 0.1.128) runs once inside the transaction, after
4716
5125
  * `$cas` extraction and validation, with the patches (identifying fields
4717
- * present, `$cas` removed) — see {@link TWriteOptions}.
5126
+ * present, `$cas` removed); `opts.check` (since 0.1.143) after every
5127
+ * phase — see {@link TWriteOptions}. The nested phases run only for the
5128
+ * rows the main patch matched; a nested TO object patches the row the
5129
+ * STORED foreign key references.
4718
5130
  */
4719
5131
  async bulkUpdate(payloads, opts) {
4720
5132
  this._ensureBuilt();
4721
- const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
5133
+ const { _depth, _action, _ownedBy, maxDepth: userMax, guard, check } = opts ?? {};
4722
5134
  const maxDepth = userMax ?? 3;
4723
5135
  const depth = _depth ?? 0;
4724
5136
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
@@ -4740,33 +5152,39 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4740
5152
  };
4741
5153
  this._applyDepthCtx(ctx, depth);
4742
5154
  validateBatch(validator, cloned, ctx);
5155
+ let guardCtx;
4743
5156
  if (guard) {
4744
- await guard(new WriteGuardContext(_action ?? "updateMany", cloned, expectedVersions, this));
5157
+ guardCtx = new WriteGuardContext(_action ?? "updateMany", cloned, expectedVersions, this, opts);
5158
+ await guard(guardCtx);
4745
5159
  validateBatch(validator, cloned, ctx);
4746
5160
  }
4747
5161
  const originals = canNest ? cloned.map((p) => ({ ...p })) : [];
4748
5162
  await this._encryptItems(cloned, "patch");
4749
5163
  const host = this;
4750
- if (canNest) await batchPatchNestedTo(host, cloned, maxDepth, depth);
5164
+ const rowFilters = this._rowFilters(cloned, opts);
5165
+ const nestedPlan = canNest ? await planNestedFromVia(host, originals, "patch") : void 0;
5166
+ const nestedTo = canNest ? cloned.map((payload) => this._carriesNestedTo(payload)) : [];
5167
+ const targets = await this._pinTargets(rowFilters, (i) => nestedTo[i] || check !== void 0 && !this._isPkFilter(rowFilters[i]), guardCtx);
5168
+ const toPlans = nestedTo.includes(true) ? await planPatchNestedTo(host, cloned, targets) : [];
4751
5169
  await this._integrity.validateForeignKeys(cloned, this._meta, this._fkLookupResolver, this._writeTableResolver, true);
4752
- let matchedCount = 0;
4753
5170
  let modifiedCount = 0;
5171
+ const matched = [];
4754
5172
  for (let i = 0; i < cloned.length; i++) {
4755
5173
  const payload = cloned[i];
4756
5174
  const expectedVersion = expectedVersions[i];
4757
5175
  const data = { ...payload };
4758
5176
  for (const navField of this._meta.navFields) delete data[navField];
4759
- const filter = this._extractRecordFilter(data, opts);
5177
+ const filter = rowFilters[i];
4760
5178
  for (const key of Object.keys(filter)) delete data[key];
4761
5179
  this._meta.stripDerived(data);
4762
5180
  if (versionColumn !== void 0) assertNoVersionWrites(data, versionColumn);
4763
5181
  const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
4764
5182
  if (isEmptyObject(data) && expectedVersion === void 0) {
4765
- const exists = await this.adapter.count({
5183
+ matched[i] = await this.adapter.count({
4766
5184
  filter: translatedFilter,
4767
5185
  controls: {}
4768
- });
4769
- matchedCount += exists > 0 ? 1 : 0;
5186
+ }) > 0;
5187
+ if (!matched[i] && _ownedBy?.strict?.[i]) throw concurrentChange();
4770
5188
  continue;
4771
5189
  }
4772
5190
  let result;
@@ -4788,13 +5206,17 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4788
5206
  result = await this.adapter.updateOne(translatedFilter, resolved, translatedOps, expectedVersion);
4789
5207
  } else result = await this.adapter.updateOne(translatedFilter, translatedUpdate, translatedOps, expectedVersion);
4790
5208
  }
4791
- matchedCount += result.matchedCount;
4792
5209
  modifiedCount += result.modifiedCount;
5210
+ matched[i] = result.matchedCount > 0;
5211
+ if (!matched[i] && _ownedBy?.strict?.[i]) throw concurrentChange();
4793
5212
  }
4794
- if (canNest) await batchPatchNestedFrom(host, originals, maxDepth, depth);
4795
- if (canNest) await batchPatchNestedVia(host, originals, maxDepth, depth);
5213
+ if (toPlans.length > 0) await applyPatchNestedTo(toPlans, maxDepth, depth, matched);
5214
+ const matchedOriginals = canNest ? originals.filter((_, i) => matched[i]) : [];
5215
+ if (matchedOriginals.length > 0) await batchPatchNestedFrom(host, matchedOriginals, maxDepth, depth, nestedPlan);
5216
+ if (matchedOriginals.length > 0) await batchPatchNestedVia(host, matchedOriginals, maxDepth, depth, nestedPlan);
5217
+ if (check) await this._runWriteCheck(check, _action ?? "updateMany", this._writtenPkFilters(rowFilters, targets, matched));
4796
5218
  return {
4797
- matchedCount,
5219
+ matchedCount: countTrue(matched),
4798
5220
  modifiedCount
4799
5221
  };
4800
5222
  }));
@@ -4879,29 +5301,40 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4879
5301
  }
4880
5302
  /**
4881
5303
  * Deletes a single record by any type-compatible identifier — primary key
4882
- * or single-field unique index. Uses the same resolution logic as `findById`.
5304
+ * or single-field unique index. Uses the same resolution logic as `findById`:
5305
+ * the id addresses exactly ONE row, primary key first (since 0.1.143 — see
5306
+ * {@link resolveRowFilter}); an id that could name several rows is pinned
5307
+ * inside the transaction, so the guard, the cascade and the delete all see
5308
+ * the same row.
4883
5309
  *
4884
5310
  * When the adapter does not support native foreign keys (e.g. MongoDB),
4885
5311
  * cascade and setNull actions are applied before the delete.
4886
5312
  *
4887
5313
  * `opts.guard` (since 0.1.128) runs inside the transaction once the id has
4888
5314
  * resolved to a filter, before cascade / delete — see {@link TDeleteOptions}.
5315
+ * `opts.scope` (since 0.1.143) is a row scope: an ambiguous id is pinned
5316
+ * among in-scope rows only (see {@link TRowResolveOptions}) and the delete
5317
+ * — guard `current()` and cascade included — targets the row only while it
5318
+ * matches the scope, so an out-of-scope row answers `{ deletedCount: 0 }`
5319
+ * exactly like a missing one.
4889
5320
  * An id that resolves to no filter answers `{ deletedCount: 0 }` without
4890
5321
  * calling the guard.
4891
5322
  */
4892
5323
  async deleteOne(id, opts) {
4893
5324
  this._ensureBuilt();
4894
- const filter = this._resolveIdFilter(id, opts);
4895
- if (!filter) return { deletedCount: 0 };
5325
+ const candidates = this._idCandidates(id, opts);
5326
+ if (candidates.length === 0) return { deletedCount: 0 };
4896
5327
  const guard = opts?.guard;
4897
5328
  const needsCascade = this._integrity.needsCascade(this._cascadeResolver);
4898
- const translated = this._fieldMapper.translateFilter(filter, this._meta);
4899
5329
  const run = async () => {
5330
+ const pinned = await this._pinIdCandidates(candidates, opts?.scope);
5331
+ const filter = this._andScope(pinned, opts?.scope);
5332
+ const translated = this._fieldMapper.translateFilter(filter, this._meta);
4900
5333
  if (guard) await guard(new RemoveGuardContext(id, filter, this));
4901
5334
  if (needsCascade) await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
4902
5335
  return this.adapter.deleteOne(translated);
4903
5336
  };
4904
- return remapDeleteFkViolation(this.tableName, () => guard || needsCascade ? this.adapter.withTransaction(run) : run());
5337
+ return remapDeleteFkViolation(this.tableName, () => guard || needsCascade || candidates.length > 1 ? this.adapter.withTransaction(run) : run());
4905
5338
  }
4906
5339
  async updateMany(filter, data) {
4907
5340
  this._ensureBuilt();
@@ -4994,23 +5427,188 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
4994
5427
  }
4995
5428
  }
4996
5429
  /**
4997
- * Lazy pre-image read for a guard's `current(i)`: `null` when the row has
4998
- * no identifying key (e.g. an auto-increment insert) or the key cannot be
4999
- * resolved — never throws for a missing key.
5430
+ * The record filter a guard's `current(i)` reads its pre-image by: `null`
5431
+ * when the row has no identifying key (e.g. an auto-increment insert) or
5432
+ * the key cannot be resolved — never throws for a missing key. Identified
5433
+ * exactly like the write itself (primary key first, then a unique index —
5434
+ * since 0.1.143), so it is always the row the write targets.
5000
5435
  * @internal
5001
5436
  */
5002
- async _readPreImage(row) {
5003
- if (!row) return null;
5004
- let filter;
5437
+ _recordFilterOrNull(row, opts) {
5438
+ if (!row || typeof row !== "object") return null;
5005
5439
  try {
5006
- filter = this._resolveIdFilter(row);
5440
+ return this._extractRecordFilter(row, opts);
5007
5441
  } catch {
5008
5442
  return null;
5009
5443
  }
5010
- if (!filter) return null;
5011
- return await this.findOne({
5012
- filter,
5013
- controls: {}
5444
+ }
5445
+ /**
5446
+ * Each item's record filter (see {@link _extractRecordFilter}). A nested
5447
+ * FROM re-entry (`_ownedBy`) also pins the child's foreign key to its
5448
+ * parent, so the write never touches a child of another parent.
5449
+ */
5450
+ _rowFilters(items, opts) {
5451
+ const owner = opts?._ownedBy?.field;
5452
+ return items.map((item) => {
5453
+ const filter = this._extractRecordFilter(item, opts);
5454
+ return owner === void 0 || item[owner] === void 0 ? filter : {
5455
+ ...filter,
5456
+ [owner]: this._prepareFilterValue(owner, item[owner])
5457
+ };
5458
+ });
5459
+ }
5460
+ /** Whether `filter` names every primary-key field (so it IS the row's exact key). */
5461
+ _isPkFilter(filter) {
5462
+ const pkFields = this.primaryKeys;
5463
+ return pkFields.length > 0 && pkFields.every((f) => filter[f] !== void 0);
5464
+ }
5465
+ /**
5466
+ * The TO relations with their single-field local foreign key (lazily
5467
+ * listed) — what a write pins per item for its nested TO phase.
5468
+ */
5469
+ _toRelationsCache;
5470
+ _toRelations() {
5471
+ if (!this._toRelationsCache) {
5472
+ const out = [];
5473
+ for (const [navField, relation] of this._meta.relations) {
5474
+ if (relation.direction !== "to") continue;
5475
+ const fk = this._findFKForRelation(relation);
5476
+ out.push({
5477
+ navField,
5478
+ fkField: fk?.localFields.length === 1 ? fk.localFields[0] : void 0
5479
+ });
5480
+ }
5481
+ this._toRelationsCache = out;
5482
+ }
5483
+ return this._toRelationsCache;
5484
+ }
5485
+ /** Whether `item` carries a nested TO object (Phase 1 work). */
5486
+ _carriesNestedTo(item) {
5487
+ return this._toRelations().some(({ navField }) => {
5488
+ const nested = item[navField];
5489
+ return !!nested && typeof nested === "object" && !Array.isArray(nested);
5490
+ });
5491
+ }
5492
+ /**
5493
+ * Reads — once per write call, inside its transaction — the stored row
5494
+ * each item `need`s, by the item's record filter: its primary key, version
5495
+ * and single-field TO foreign keys, in ONE read for the batch. A pre-image
5496
+ * the guard's `current(i)` already read by the same filter is reused.
5497
+ * `null` = no such row; `undefined` = not needed.
5498
+ */
5499
+ async _pinTargets(rowFilters, need, guardCtx) {
5500
+ const targets = [];
5501
+ const pending = [];
5502
+ for (let i = 0; i < rowFilters.length; i++) {
5503
+ if (!need(i)) continue;
5504
+ const read = guardCtx?.readFor(i, rowFilters[i]);
5505
+ if (read) targets[i] = await read;
5506
+ else pending.push(i);
5507
+ }
5508
+ if (pending.length === 0) return targets;
5509
+ const select = new Set(this.primaryKeys);
5510
+ const versionField = this._meta.versionField;
5511
+ if (versionField !== void 0) select.add(versionField);
5512
+ for (const { fkField } of this._toRelations()) if (fkField !== void 0) select.add(fkField);
5513
+ for (const i of pending) for (const field in rowFilters[i]) select.add(field);
5514
+ const controls = { $select: [...select] };
5515
+ const filters = pending.map((i) => rowFilters[i]);
5516
+ const rows = await this.findMany({
5517
+ filter: filters.length === 1 ? filters[0] : { $or: filters },
5518
+ controls
5519
+ });
5520
+ const used = /* @__PURE__ */ new Set();
5521
+ for (const i of pending) {
5522
+ const row = rows.find((r) => rowMatchesKey(r, rowFilters[i]));
5523
+ targets[i] = row ?? null;
5524
+ if (row) used.add(row);
5525
+ }
5526
+ if (used.size < rows.length) {
5527
+ for (const i of pending) if (targets[i] === null) targets[i] = await this.findOne({
5528
+ filter: rowFilters[i],
5529
+ controls
5530
+ });
5531
+ }
5532
+ return targets;
5533
+ }
5534
+ /**
5535
+ * The exact primary-key filter of every row the write matched: the record
5536
+ * filter itself when it is the primary key, else the pinned row's key.
5537
+ */
5538
+ _writtenPkFilters(rowFilters, targets, matched) {
5539
+ const out = [];
5540
+ for (let i = 0; i < rowFilters.length; i++) {
5541
+ if (!matched[i]) continue;
5542
+ const target = targets[i];
5543
+ const filter = this._isPkFilter(rowFilters[i]) ? rowFilters[i] : target ? this._pkFilterFrom(target) : null;
5544
+ if (filter) out.push(filter);
5545
+ }
5546
+ return out;
5547
+ }
5548
+ /** Invokes a {@link TWriteOptions.check} with de-duplicated PK filters (inside the transaction). */
5549
+ async _runWriteCheck(check, action, filters) {
5550
+ const pkFields = this.primaryKeys;
5551
+ const seen = /* @__PURE__ */ new Set();
5552
+ const unique = [];
5553
+ for (const filter of filters) {
5554
+ const key = pkTupleKey(filter, pkFields);
5555
+ if (seen.has(key)) continue;
5556
+ seen.add(key);
5557
+ unique.push(filter);
5558
+ }
5559
+ await check({
5560
+ action,
5561
+ filters: unique,
5562
+ transactional: this.adapter.isInTransaction(),
5563
+ count: (filter) => this.count({
5564
+ filter,
5565
+ controls: {}
5566
+ })
5567
+ });
5568
+ }
5569
+ /**
5570
+ * The exact primary-key filter of each inserted row: the logical key from
5571
+ * the row (SDK defaults applied), else the stored key the adapter wrote into
5572
+ * the prepared row (e.g. a driver-assigned `_id`), else — single-field keys
5573
+ * only — the adapter's `insertedIds` (auto-increment).
5574
+ */
5575
+ _insertedPkFilters(items, prepared, insertedIds) {
5576
+ const pkFields = this.primaryKeys;
5577
+ if (pkFields.length === 0 && items.length > 0) throw new DbError("INVALID_QUERY", [{
5578
+ path: "",
5579
+ message: "Write check requires a primary key on the table"
5580
+ }]);
5581
+ return items.map((item, i) => {
5582
+ const key = {};
5583
+ for (const field of pkFields) {
5584
+ let value = item[field];
5585
+ if (value === void 0) value = prepared[i]?.[this._meta.columnMap.get(field) ?? field];
5586
+ if (value === void 0 && pkFields.length === 1) value = insertedIds[i];
5587
+ if (value === void 0 || value === null) throw new DbError("INVALID_QUERY", [{
5588
+ path: field,
5589
+ message: `Write check: cannot resolve the primary key of inserted row [${i}]`
5590
+ }]);
5591
+ key[field] = value;
5592
+ }
5593
+ return this._pkFilterFrom(key);
5594
+ });
5595
+ }
5596
+ /**
5597
+ * Drops the nested TO objects of every replace item whose main replace
5598
+ * cannot match (pinned row missing, or stale `$cas`) so Phase 1 never
5599
+ * writes a related row for a replace that does nothing. Returns, per item,
5600
+ * whether its TO objects stay (a later 0-match for such an item is a
5601
+ * concurrent change and rolls back).
5602
+ */
5603
+ _gateNestedTo(items, nestedTo, targets, expectedVersions) {
5604
+ const versionField = this._meta.versionField;
5605
+ return items.map((item, i) => {
5606
+ if (!nestedTo[i]) return false;
5607
+ const row = targets[i];
5608
+ const expected = expectedVersions[i];
5609
+ const kept = !!row && (expected === void 0 || versionField === void 0 || sameKey(row[versionField], expected));
5610
+ if (!kept) for (const { navField } of this._toRelations()) delete item[navField];
5611
+ return kept;
5014
5612
  });
5015
5613
  }
5016
5614
  /**
@@ -5059,6 +5657,19 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
5059
5657
  }
5060
5658
  }
5061
5659
  /**
5660
+ * The filter an `updateOne` / `replaceOne` of `payload` targets its row by
5661
+ * — the write's own resolution (primary key first, then a unique index;
5662
+ * see {@link _extractRecordFilter}), so a caller explaining a write's
5663
+ * outcome (e.g. a CAS mismatch) reads exactly the row the write addressed.
5664
+ * Throws `NOT_FOUND` when the payload carries no identifying fields.
5665
+ *
5666
+ * @since 0.1.143
5667
+ */
5668
+ recordFilter(payload, opts) {
5669
+ this._ensureBuilt();
5670
+ return this._extractRecordFilter(payload, opts);
5671
+ }
5672
+ /**
5062
5673
  * Extracts a record-identifying filter from a payload.
5063
5674
  *
5064
5675
  * Resolution order:
@@ -5072,18 +5683,8 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
5072
5683
  */
5073
5684
  _extractRecordFilter(payload, opts) {
5074
5685
  const pkFields = this.primaryKeys;
5075
- if (pkFields.length > 0) {
5076
- let allPresent = true;
5077
- for (const field of pkFields) if (payload[field] === void 0) {
5078
- allPresent = false;
5079
- break;
5080
- }
5081
- if (allPresent) {
5082
- const filter = {};
5083
- for (const field of pkFields) filter[field] = this._prepareFilterValue(field, payload[field]);
5084
- return filter;
5085
- }
5086
- }
5686
+ const pkFilter = this._pkFilterFrom(payload);
5687
+ if (pkFilter) return pkFilter;
5087
5688
  const identifications = this.identificationsVisibleTo(opts?.isFieldVisible);
5088
5689
  const singleFields = /* @__PURE__ */ new Set();
5089
5690
  for (const ident of identifications) if (ident.source !== "primaryKey" && ident.fields.length === 1) singleFields.add(ident.fields[0]);
@@ -5221,6 +5822,81 @@ function readViewAgg(metadata) {
5221
5822
  };
5222
5823
  }
5223
5824
  }
5825
+ /** The type getter of a `@db.view.for` / join / chain ref (a bare getter or `{ type }`). */
5826
+ function refType(ref) {
5827
+ return typeof ref === "function" ? ref : ref.type;
5828
+ }
5829
+ /**
5830
+ * Where a view field reads from: its chain ref, else — on the entry table —
5831
+ * the aggregate's field, else the same-named field. `sourceType` is
5832
+ * `undefined` for an external view's untyped field (no entry table).
5833
+ */
5834
+ function viewFieldSource(fieldName, fieldType, entryType) {
5835
+ const agg = readViewAgg(fieldType.metadata);
5836
+ const chainRef = fieldType.ref?.field ? fieldType.ref : void 0;
5837
+ if (chainRef) return {
5838
+ agg,
5839
+ chained: true,
5840
+ sourceType: chainRef.type(),
5841
+ sourcePath: chainRef.field
5842
+ };
5843
+ const aggField = agg?.aggField;
5844
+ return {
5845
+ agg,
5846
+ chained: false,
5847
+ sourceType: entryType?.(),
5848
+ sourcePath: aggField && aggField !== "*" ? aggField : fieldName
5849
+ };
5850
+ }
5851
+ /** View types whose fields already carry their inherited seals. */
5852
+ const sealedViews = /* @__PURE__ */ new WeakSet();
5853
+ /**
5854
+ * Whether `type` declares a view (managed `@db.view.for` or external
5855
+ * `@db.view`) — how `DbSpace.get` tells views from tables.
5856
+ * @since 0.1.143
5857
+ */
5858
+ function isViewType(type) {
5859
+ return type.metadata.has("db.view") || type.metadata.has("db.view.for");
5860
+ }
5861
+ /**
5862
+ * Carries the read seals of each view column's source field onto the view
5863
+ * type's own field metadata (since 0.1.143), so a view never exposes a value
5864
+ * differently from the table it reads:
5865
+ *
5866
+ * - `@db.writeOnly` — a column reading a write-only field (or a leaf of a
5867
+ * write-only object), or aggregating one, is write-only on the view too, so
5868
+ * HTTP layers seal it exactly as on the table.
5869
+ * - `@db.encrypted` — a column reading an encrypted field reads its
5870
+ * ciphertext, so it is encrypted on the view too: rows come back decrypted
5871
+ * and filters / sorts on it are rejected, as on the table. An aggregate over
5872
+ * an encrypted field is rejected (the table refuses it as well).
5873
+ *
5874
+ * The source is resolved exactly like {@link AtscriptDbView.getViewColumnMappings}
5875
+ * does ({@link viewFieldSource}); a source that is itself a view is sealed
5876
+ * first. Runs once per view type, when a view over it is first built.
5877
+ */
5878
+ function inheritViewFieldSeals(viewType) {
5879
+ if (sealedViews.has(viewType) || viewType.type.kind !== "object") return;
5880
+ sealedViews.add(viewType);
5881
+ try {
5882
+ const forRef = viewType.metadata.get("db.view.for");
5883
+ const entryType = forRef && refType(forRef);
5884
+ for (const [fieldName, fieldType] of viewType.type.props.entries()) {
5885
+ const { agg, sourceType, sourcePath } = viewFieldSource(fieldName, fieldType, entryType);
5886
+ if (agg?.aggField === "*" || !sourceType) continue;
5887
+ const source = viewSourceOf(sourceType).type;
5888
+ if (isViewType(source)) inheritViewFieldSeals(source);
5889
+ const seals = sourceFieldSeals(source, sourcePath);
5890
+ if (seals.writeOnly) fieldType.metadata.set("db.writeOnly", true);
5891
+ if (!seals.encrypted) continue;
5892
+ if (agg) throw new Error(`View "${tableNameOf(viewType)}" field "${fieldName}": @db.agg.${agg.aggFn} over the @db.encrypted field "${sourcePath}" — ciphertext cannot be aggregated`);
5893
+ fieldType.metadata.set("db.encrypted", true);
5894
+ }
5895
+ } catch (error) {
5896
+ sealedViews.delete(viewType);
5897
+ throw error;
5898
+ }
5899
+ }
5224
5900
  /**
5225
5901
  * Database view abstraction driven by Atscript `@db.view.*` annotations.
5226
5902
  *
@@ -5241,6 +5917,15 @@ var AtscriptDbView = class extends AtscriptDbReadable {
5241
5917
  return true;
5242
5918
  }
5243
5919
  /**
5920
+ * Builds the view's metadata — first stamping its fields with their
5921
+ * sources' read seals (`inheritViewFieldSeals`, once per view type), so
5922
+ * every metadata consumer sees the sealed type.
5923
+ */
5924
+ _ensureBuilt() {
5925
+ if (!this._meta.isBuilt) inheritViewFieldSeals(this._type);
5926
+ super._ensureBuilt();
5927
+ }
5928
+ /**
5244
5929
  * Whether this is an external view — declared with `@db.view` only,
5245
5930
  * without `@db.view.for`. External views reference pre-existing DB views
5246
5931
  * and are not managed (created/dropped) by schema sync.
@@ -5260,16 +5945,14 @@ var AtscriptDbView = class extends AtscriptDbReadable {
5260
5945
  if (this._viewPlan) return this._viewPlan;
5261
5946
  if (this.isExternal) throw new Error(`Cannot compute view plan for external view "${this.tableName}". External views (declared without @db.view.for) reference pre-existing DB views.`);
5262
5947
  const metadata = this._type.metadata;
5263
- const forRef = metadata.get("db.view.for");
5264
- const entryType = typeof forRef === "function" ? forRef : forRef.type;
5948
+ const entryType = refType(metadata.get("db.view.for"));
5265
5949
  const entry = viewSourceOf(entryType());
5266
5950
  if (entry.alias) throw new Error(`View "${this.tableName}": @db.view.for "${entry.name}" is a @db.alias — a join alias cannot be the entry table`);
5267
5951
  const entryTable = entry.table;
5268
5952
  const rawJoins = metadata.get("db.view.joins");
5269
5953
  const joins = [];
5270
5954
  if (rawJoins) for (const join of rawJoins) {
5271
- const targetRef = join.target;
5272
- const targetType = typeof targetRef === "function" ? targetRef : targetRef.type;
5955
+ const targetType = refType(join.target);
5273
5956
  const target = viewSourceOf(targetType());
5274
5957
  joins.push({
5275
5958
  targetType,
@@ -5368,23 +6051,13 @@ var AtscriptDbView = class extends AtscriptDbReadable {
5368
6051
  const leftJoined = new Set(plan.joins.filter((j) => j.kind === "left").map((j) => j.scope));
5369
6052
  for (const [fieldName, fieldType] of this._type.type.props.entries()) {
5370
6053
  if (ignored.has(fieldName)) continue;
5371
- const agg = readViewAgg(fieldType.metadata);
6054
+ const { agg, chained, sourceType, sourcePath } = viewFieldSource(fieldName, fieldType, plan.entryType);
5372
6055
  const aggField = agg?.aggField;
5373
6056
  if (aggField === "*" && agg?.aggFn !== "count") fail(fieldName, `aggregate "${agg?.aggFn}" needs a field — only count accepts *`);
5374
- let sourceType;
5375
- let sourcePath;
5376
- const chainRef = fieldType.ref?.field ? fieldType.ref : void 0;
5377
- if (chainRef) {
5378
- sourceType = chainRef.type();
5379
- sourcePath = chainRef.field;
5380
- } else {
5381
- sourceType = plan.entryType();
5382
- sourcePath = aggField && aggField !== "*" ? aggField : fieldName;
5383
- }
5384
6057
  const src = viewSourceOf(sourceType);
5385
6058
  const sourceTable = src.name;
5386
6059
  const joinNullable = leftJoined.has(sourceTable);
5387
- if (aggField === "*" && !chainRef) {
6060
+ if (aggField === "*" && !chained) {
5388
6061
  mappings.push({
5389
6062
  viewColumn: viewName(fieldName) ?? fieldName,
5390
6063
  viewPath: fieldName,
@@ -5845,4 +6518,4 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
5845
6518
  return diff;
5846
6519
  }
5847
6520
  //#endregion
5848
- export { isJsonValueField as $, assertGeoPoint as A, guardQuery as B, IntegrityStrategy as C, ADAPTER_FILTER_REASON as D, resolveDesignType as E, collectQueryPaths as F, DocumentFieldMapper as G, sortFieldNames as H, guardAggregate as I, TableMetadata as J, FieldMappingStrategy as K, guardFilter as L, canFilterLeaf as M, checkHavingKeys as N, ENCRYPTED_REASON as O, classifyQueryPath as P, isBucketableField as Q, guardPath as R, createFailureCollector as S, AtscriptDbReadable as T, unsupportedOperatorMessage as U, narrowerFilterOps as V, RelationalFieldMapper as W, isGeoPointType as X, isGeoIndexableType as Y, aliasTargetOf as Z, assertNoVersionWrites as _, hasForeignKeyChanges as a, ALL_BUCKET_UNITS as b, computeTableHash as c, snapshotToExistingColumns as d, jsonValueAncestor as et, snapshotToExistingTableOptions as f, AtscriptDbTable as g, isAtscriptDbView as h, fkKey as i, bucketSourceVerdict as j, acceptedOperatorsHint as k, computeTableSnapshot as l, AtscriptDbView as m, isColumnTypeChanged as n, resolveCalendarBuckets as nt, canonicalizeQueryNode as o, viewSnapshotSources as p, UniquSelect as q, computeForeignKeyDiff as r, NoopLogger as rt, computeSchemaHash as s, computeColumnDiff as t, normalizeComputedSelect as tt, computeViewSnapshot as u, decomposePatch as v, NativeIntegrity as w, BaseDbAdapter as x, ApplicationIntegrity as y, guardPaths as z };
6521
+ export { isGeoIndexableType as $, vectorIndexNotFoundMessage as A, guardAggregate as B, createFailureCollector as C, resolveDesignType as D, AtscriptDbReadable as E, bucketSourceVerdict as F, narrowerFilterOps as G, guardPath as H, canFilterLeaf as I, RelationalFieldMapper as J, sortFieldNames as K, checkHavingKeys as L, ENCRYPTED_REASON as M, acceptedOperatorsHint as N, geoIndexNotFoundMessage as O, assertGeoPoint as P, TableMetadata as Q, classifyQueryPath as R, BaseDbAdapter as S, NativeIntegrity as T, guardPaths as U, guardFilter as V, guardQuery as W, FieldMappingStrategy as X, DocumentFieldMapper as Y, UniquSelect as Z, AtscriptDbTable as _, hasForeignKeyChanges as a, normalizeComputedSelect as at, ApplicationIntegrity as b, computeTableHash as c, snapshotToExistingColumns as d, isGeoPointType as et, snapshotToExistingTableOptions as f, isViewType as g, isAtscriptDbView as h, fkKey as i, jsonValueAncestor as it, ADAPTER_FILTER_REASON as j, searchIndexNotFoundMessage as k, computeTableSnapshot as l, AtscriptDbView as m, isColumnTypeChanged as n, isBucketableField as nt, canonicalizeQueryNode as o, resolveCalendarBuckets as ot, viewSnapshotSources as p, unsupportedOperatorMessage as q, computeForeignKeyDiff as r, isJsonValueField as rt, computeSchemaHash as s, NoopLogger as st, computeColumnDiff as t, aliasTargetOf as tt, computeViewSnapshot as u, assertNoVersionWrites as v, IntegrityStrategy as w, ALL_BUCKET_UNITS as x, decomposePatch as y, collectQueryPaths as z };