@atscript/db 0.1.127 → 0.1.129

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 (41) hide show
  1. package/dist/{db-error-DXwEzmYJ.cjs → db-error-C4JuLcvb.cjs} +27 -0
  2. package/dist/{db-error-BHPXOKzc.mjs → db-error-COrO58t5.mjs} +22 -1
  3. package/dist/{db-readable-BkAGccv9.d.mts → db-readable-B7eYWS5q.d.cts} +299 -17
  4. package/dist/{db-readable-C0nDKX8A.d.cts → db-readable-Bn1bV_eC.d.mts} +299 -17
  5. package/dist/{db-space-B_ASuDaR.d.mts → db-space-C2UCnGHd.d.cts} +108 -30
  6. package/dist/{db-space-CSntT6yS.d.cts → db-space-DdIPYD0Q.d.mts} +108 -30
  7. package/dist/{db-view-BP0Qbeux.cjs → db-view-CRBgkEp0.cjs} +786 -100
  8. package/dist/{db-view-C8rZM5_N.mjs → db-view-Dl0aDTiT.mjs} +733 -101
  9. package/dist/index.cjs +48 -3
  10. package/dist/index.d.cts +162 -37
  11. package/dist/index.d.mts +162 -37
  12. package/dist/index.mjs +37 -5
  13. package/dist/{nested-writer-DI-HeTky.mjs → nested-writer-CkDo-ZfH.mjs} +1 -1
  14. package/dist/{nested-writer-DoDhl3X3.cjs → nested-writer-DxPhmWFz.cjs} +1 -1
  15. package/dist/{ops-DJRnNTVo.d.cts → ops-AqhV7s9o.d.cts} +24 -1
  16. package/dist/{ops-DJRnNTVo.d.mts → ops-AqhV7s9o.d.mts} +24 -1
  17. package/dist/ops.cjs +44 -1
  18. package/dist/ops.d.cts +2 -2
  19. package/dist/ops.d.mts +2 -2
  20. package/dist/ops.mjs +44 -2
  21. package/dist/plugin.cjs +12 -5
  22. package/dist/plugin.mjs +12 -5
  23. package/dist/rel.cjs +2 -2
  24. package/dist/rel.d.cts +1 -1
  25. package/dist/rel.d.mts +1 -1
  26. package/dist/rel.mjs +2 -2
  27. package/dist/{relation-loader-BnUgJsUG.cjs → relation-loader-C8GOpNYJ.cjs} +1 -1
  28. package/dist/{relation-loader-BmeOMj0b.mjs → relation-loader-CUGcxJ18.mjs} +1 -1
  29. package/dist/sync.cjs +1270 -398
  30. package/dist/sync.d.cts +255 -18
  31. package/dist/sync.d.mts +255 -18
  32. package/dist/sync.mjs +1269 -399
  33. package/dist/{validator-0vRXN51D.mjs → validator-CeD_fqyW.mjs} +21 -4
  34. package/dist/{validator-CSGug4vg.cjs → validator-lkCJKuoo.cjs} +32 -3
  35. package/dist/{validator-BcBtg8yW.d.cts → validator-wBARmD68.d.cts} +57 -1
  36. package/dist/{validator-BcBtg8yW.d.mts → validator-wBARmD68.d.mts} +57 -1
  37. package/dist/validator.cjs +7 -1
  38. package/dist/validator.d.cts +3 -3
  39. package/dist/validator.d.mts +3 -3
  40. package/dist/validator.mjs +4 -3
  41. package/package.json +8 -8
@@ -1,8 +1,8 @@
1
- import { n as DbError } from "./db-error-BHPXOKzc.mjs";
1
+ import { n as CasMismatchError, r as DbError } from "./db-error-COrO58t5.mjs";
2
+ import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, l as isEmptyObject, n as buildPatchPartial, t as buildDbValidator, u as isPlainObject } from "./validator-CeD_fqyW.mjs";
2
3
  import { resolveAlias } from "./agg.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 } from "./nested-writer-DI-HeTky.mjs";
4
+ 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-CkDo-ZfH.mjs";
4
5
  import { separateCas, separateFieldOps } from "./ops.mjs";
5
- import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, n as buildPatchPartial, t as buildDbValidator } from "./validator-0vRXN51D.mjs";
6
6
  import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
7
7
  import { AsyncLocalStorage } from "node:async_hooks";
8
8
  //#region src/logger.ts
@@ -108,6 +108,22 @@ var TableMetadata = class {
108
108
  leafByPhysical = /* @__PURE__ */ new Map();
109
109
  /** Leaf field descriptors indexed by logical path (write/patch/filter paths). */
110
110
  leafByLogical = /* @__PURE__ */ new Map();
111
+ /**
112
+ * Non-ignored field descriptors keyed by logical path, excluding navigation
113
+ * relations and their descendants. Unlike `leafByLogical` (relational
114
+ * adapters only) this is built for every adapter, so the core path guard
115
+ * (`guardPaths`) can answer "does this path have physical storage here?"
116
+ * on nested-object adapters too.
117
+ */
118
+ descriptorByPath = /* @__PURE__ */ new Map();
119
+ /**
120
+ * Logical paths stored as a single JSON column (`storage === 'json'`,
121
+ * non-ignored descriptors). Retained after build — unlike the build-time
122
+ * `jsonFields` set — so the path guard can classify JSON descendants on
123
+ * relational adapters. Empty on nested-object adapters (they keep native
124
+ * dotted paths as descriptors).
125
+ */
126
+ jsonParents = /* @__PURE__ */ new Set();
111
127
  _built = false;
112
128
  _identifications;
113
129
  _collateMap = /* @__PURE__ */ new Map();
@@ -160,6 +176,7 @@ var TableMetadata = class {
160
176
  const overrides = adapter.getMetadataOverrides?.(this);
161
177
  if (overrides) this._applyOverrides(overrides);
162
178
  this._buildFieldDescriptors(adapter);
179
+ this._buildGuardIndexes();
163
180
  if (!this.nestedObjects) this._buildLeafIndexes();
164
181
  this._buildIdentifications();
165
182
  this._resolvePreferredId(type);
@@ -399,6 +416,23 @@ var TableMetadata = class {
399
416
  const lastDot = path.lastIndexOf(".");
400
417
  return lastDot >= 0 ? `${path.slice(0, lastDot).replace(/\./g, "__")}__` : "";
401
418
  }
419
+ /** Nearest `@db.encrypted` ancestor of `path` (exclusive), or `undefined`. */
420
+ /**
421
+ * Indexes non-ignored descriptors by logical path and retains the JSON-parent
422
+ * set. Navigation relations and their descendants are skipped even when the
423
+ * adapter keeps them as descriptors (nested-object adapters do) — they are
424
+ * loaded with `$with`, never addressed as columns of this table.
425
+ */
426
+ _buildGuardIndexes() {
427
+ const jsonParents = /* @__PURE__ */ new Set();
428
+ for (const fd of this.fieldDescriptors) {
429
+ if (fd.ignored) continue;
430
+ if (this.navFields.has(fd.path) || findAncestorInSet(fd.path, this.navFields) !== void 0) continue;
431
+ this.descriptorByPath.set(fd.path, fd);
432
+ if (fd.storage === "json") jsonParents.add(fd.path);
433
+ }
434
+ this.jsonParents = jsonParents;
435
+ }
402
436
  /**
403
437
  * Indexes `fieldDescriptors` into two lookup maps for unified
404
438
  * read/write field classification in the RelationalFieldMapper.
@@ -821,8 +855,7 @@ var FieldMappingStrategy = class {
821
855
  if (!fmt) return value;
822
856
  if (value === null || value === void 0) return value;
823
857
  if (typeof value !== "object" || Array.isArray(value)) return fmt(value);
824
- const proto = Object.getPrototypeOf(value);
825
- if (proto !== Object.prototype && proto !== null) return fmt(value);
858
+ if (!isPlainObject(value)) return fmt(value);
826
859
  const ops = value;
827
860
  const formatted = {};
828
861
  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));
@@ -1165,6 +1198,11 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
1165
1198
  * - `$geoWithin` on a non-geoPoint field → `FILTER_TYPE_MISMATCH`
1166
1199
  * - `$geoWithin` with a malformed circle → `INVALID_QUERY`
1167
1200
  * - `$geoWithin` on an adapter without geo support → `GEO_NOT_SUPPORTED`
1201
+ * - every filter / `$sort` / `$select` / `$groupBy` / `$having` / aggregate
1202
+ * path must resolve to physical storage on THIS adapter and pass the
1203
+ * adapter's `canFilterField` / `canSortField` → `INVALID_QUERY`
1204
+ * (see {@link guardPaths}). Runs after the checks above so `ENC_*` codes
1205
+ * keep firing first for encrypted subtrees.
1168
1206
  */
1169
1207
  /** Validates a `[lng, lat]` tuple (GeoJSON coordinate order). */
1170
1208
  function assertGeoPoint(point, path) {
@@ -1231,24 +1269,274 @@ function guardSort(meta, sort) {
1231
1269
  if (!sort || typeof sort !== "object" || meta.encryptedFields.size === 0) return;
1232
1270
  for (const key of Object.keys(sort)) if (isEncryptedRef(meta, key)) throw encryptedRefError("ENC_FIELD_SORT", key, "sort by");
1233
1271
  }
1234
- /** Shared read-path guard: filter + $sort. */
1272
+ const OP_VERB = {
1273
+ filter: "filter on",
1274
+ sort: "sort by",
1275
+ select: "select",
1276
+ groupBy: "group by",
1277
+ having: "filter ($having) on",
1278
+ aggregate: "aggregate over"
1279
+ };
1280
+ function pathError(path, message) {
1281
+ return new DbError("INVALID_QUERY", [{
1282
+ path,
1283
+ message
1284
+ }]);
1285
+ }
1286
+ /**
1287
+ * The one wording for a filter-node `$`-key that is not `$and` / `$or` /
1288
+ * `$not` (uniqu's walker would treat it as a field named `$…`). The core
1289
+ * guard and the HTTP gate both answer with it; `path` is the operator itself.
1290
+ */
1291
+ function unsupportedOperatorMessage(op) {
1292
+ return `Unsupported filter operator "${op}" — use $and, $or or $not`;
1293
+ }
1294
+ /**
1295
+ * Normalizes every accepted `$sort` form (`"a,-b"`, `["a", "-b"]`,
1296
+ * `[{ a: 1 }]`, `{ a: 1 }`) into its field names. Mirrors the HTTP layer's
1297
+ * walker so programmatic callers get the same acceptance set.
1298
+ */
1299
+ function sortFieldNames(sort) {
1300
+ if (!sort) return [];
1301
+ if (typeof sort === "string") {
1302
+ const out = [];
1303
+ for (const part of sort.split(",")) {
1304
+ const name = part.trim().replace(/^[-+]/, "").split(":")[0];
1305
+ if (name) out.push(name);
1306
+ }
1307
+ return out;
1308
+ }
1309
+ if (Array.isArray(sort)) {
1310
+ const out = [];
1311
+ for (const entry of sort) if (typeof entry === "string") out.push(entry.replace(/^[-+]/, ""));
1312
+ else if (entry && typeof entry === "object") out.push(...Object.keys(entry));
1313
+ return out;
1314
+ }
1315
+ if (typeof sort === "object") return Object.keys(sort);
1316
+ return [];
1317
+ }
1318
+ function collectFilterKeys(filter, out, geo, skip, refs) {
1319
+ if (!filter || typeof filter !== "object" || Array.isArray(filter)) return;
1320
+ for (const [key, value] of Object.entries(filter)) {
1321
+ if (refs.unsupportedOperator) return;
1322
+ if (key === "$and" || key === "$or") {
1323
+ if (Array.isArray(value)) for (const child of value) collectFilterKeys(child, out, geo, skip, refs);
1324
+ continue;
1325
+ }
1326
+ if (key === "$not") {
1327
+ collectFilterKeys(value, out, geo, skip, refs);
1328
+ continue;
1329
+ }
1330
+ if (key.startsWith("$")) {
1331
+ refs.unsupportedOperator = key;
1332
+ return;
1333
+ }
1334
+ if (skip?.has(key)) continue;
1335
+ (geo !== void 0 && value !== null && typeof value === "object" && !Array.isArray(value) && "$geoWithin" in value ? geo : out).push(key);
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Walks the PARSED query structure (not the flattened insights map, which
1340
+ * cannot tell `$select=assignee.name` from `$with=assignee($select=name)`)
1341
+ * and returns the root paths per position. `$with` sub-trees are not
1342
+ * visited — they are validated against their target relation separately.
1343
+ *
1344
+ * Aggregate mode is `aggregate` when given, else the presence of `$groupBy`.
1345
+ * In aggregate mode `$select` entries are aggregate expressions whose
1346
+ * `$field` is collected and whose alias (`$as`, else `fn_field` — the core's
1347
+ * `resolveAlias`) is exempted from `$sort` / `$having`; outside it non-string
1348
+ * `$select` entries are ignored (the projection seal handles them).
1349
+ */
1350
+ function collectQueryPaths(query, aggregate) {
1351
+ const refs = {
1352
+ filter: [],
1353
+ geoFilter: [],
1354
+ sort: [],
1355
+ select: [],
1356
+ groupBy: [],
1357
+ having: [],
1358
+ aggregate: [],
1359
+ aggregateMode: false
1360
+ };
1361
+ collectFilterKeys(query.filter, refs.filter, refs.geoFilter, void 0, refs);
1362
+ const controls = query.controls ?? {};
1363
+ const rawGroupBy = controls.$groupBy;
1364
+ const groupBy = Array.isArray(rawGroupBy) ? rawGroupBy.filter((f) => typeof f === "string") : typeof rawGroupBy === "string" ? [rawGroupBy] : [];
1365
+ refs.aggregateMode = aggregate ?? groupBy.length > 0;
1366
+ refs.groupBy = groupBy;
1367
+ const aliases = /* @__PURE__ */ new Set();
1368
+ const select = controls.$select;
1369
+ if (Array.isArray(select)) {
1370
+ for (const item of select) if (typeof item === "string") refs.select.push(item);
1371
+ else if (refs.aggregateMode && item && typeof item === "object" && "$field" in item) {
1372
+ const expr = item;
1373
+ aliases.add(resolveAlias(expr));
1374
+ if (expr.$field !== "*") refs.aggregate.push(expr.$field);
1375
+ }
1376
+ } else if (select && typeof select === "object") refs.select.push(...Object.keys(select));
1377
+ for (const name of sortFieldNames(controls.$sort)) if (!aliases.has(name)) refs.sort.push(name);
1378
+ if (refs.aggregateMode) collectFilterKeys(controls.$having, refs.having, void 0, aliases, refs);
1379
+ return refs;
1380
+ }
1381
+ /**
1382
+ * Classifies one logical path. The rules exist once, in this order, for the
1383
+ * core backstop ({@link guardPath}) and the HTTP capability gate alike:
1384
+ *
1385
+ * 1. navigation relations (and anything under them) — `parent` is the nav
1386
+ * head when the path is a descendant;
1387
+ * 2. a stored leaf;
1388
+ * 3. a nested-object parent;
1389
+ * 4. a descendant of a JSON-stored column — `parent` names the column;
1390
+ * 5. a descendant of an `@db.encrypted` field — `parent` names the field;
1391
+ * 6. unknown.
1392
+ */
1393
+ function classifyQueryPath(source, path) {
1394
+ if (source.navFields.has(path)) return { kind: "nav" };
1395
+ const navHead = findAncestorInSet(path, source.navFields);
1396
+ if (navHead !== void 0) return {
1397
+ kind: "nav",
1398
+ parent: navHead
1399
+ };
1400
+ if (source.leaves.has(path)) return { kind: "leaf" };
1401
+ if (source.objectParents.has(path)) return { kind: "objectParent" };
1402
+ const jsonParent = findAncestorInSet(path, source.jsonParents);
1403
+ if (jsonParent !== void 0) return {
1404
+ kind: "jsonDescendant",
1405
+ parent: jsonParent
1406
+ };
1407
+ const encryptedParent = findAncestorInSet(path, source.encryptedFields);
1408
+ if (encryptedParent !== void 0) return {
1409
+ kind: "encryptedDescendant",
1410
+ parent: encryptedParent
1411
+ };
1412
+ return { kind: "unknown" };
1413
+ }
1414
+ /** `TableMetadata` as a {@link TQueryPathSource} (descriptors are the leaves, flattened parents the object parents). */
1415
+ function pathSourceOf(meta) {
1416
+ return {
1417
+ navFields: meta.navFields,
1418
+ leaves: meta.descriptorByPath,
1419
+ objectParents: meta.flattenedParents,
1420
+ jsonParents: meta.jsonParents,
1421
+ encryptedFields: meta.encryptedFields
1422
+ };
1423
+ }
1424
+ /**
1425
+ * Validates ONE logical path for ONE query position against this table's
1426
+ * metadata and adapter capability — the classification of
1427
+ * {@link classifyQueryPath} plus the position's physical requirement:
1428
+ *
1429
+ * - a leaf → physical capability (`canFilterField` / `canSortField`;
1430
+ * `$select` always passes);
1431
+ * - a nested-object parent → only `$select`, and only when it expands to
1432
+ * leaf columns (`selectExpansion`);
1433
+ * - everything else is rejected.
1434
+ *
1435
+ * `geoPredicate` marks a filter entry whose operator is `$geoWithin`: its
1436
+ * shape and index support were already validated by {@link guardFilter}, so
1437
+ * the adapter's scalar `canFilterField` veto does not apply.
1438
+ *
1439
+ * Messages are the short programmatic forms; the HTTP wording (moost-db's
1440
+ * `FieldCapabilityIndex`, with `$with` hints and leaf lists) is what clients
1441
+ * see and is authoritative — the HTTP gate always answers first.
1442
+ */
1443
+ function guardPath(meta, adapter, path, op, geoPredicate = false) {
1444
+ const verb = OP_VERB[op];
1445
+ const { kind, parent } = classifyQueryPath(pathSourceOf(meta), path);
1446
+ switch (kind) {
1447
+ case "nav": throw pathError(path, `Cannot ${verb} "${path}" — navigation path`);
1448
+ case "leaf": {
1449
+ if (op === "select") return;
1450
+ const fd = meta.descriptorByPath.get(path);
1451
+ if (op === "sort") {
1452
+ if (!adapter.canSortField(fd)) throw pathError(path, `Cannot sort by "${path}" — adapter cannot sort on this storage type`);
1453
+ return;
1454
+ }
1455
+ if (geoPredicate) return;
1456
+ if (!adapter.canFilterField(fd)) throw pathError(path, `Cannot ${verb} "${path}" — adapter cannot filter on this storage type`);
1457
+ return;
1458
+ }
1459
+ case "objectParent":
1460
+ if (op === "select" && meta.selectExpansion.has(path)) return;
1461
+ throw pathError(path, `Cannot ${verb} "${path}" — nested object; use one of its leaf fields`);
1462
+ case "jsonDescendant": throw pathError(path, `Cannot ${verb} "${path}" — inside JSON-stored column "${parent}"`);
1463
+ case "encryptedDescendant": throw pathError(path, op === "select" ? `Cannot select "${path}" — inside encrypted field "${parent}"; select "${parent}" instead` : `Cannot ${verb} encrypted field "${path}"`);
1464
+ default: throw pathError(path, `Unknown field "${path}"`);
1465
+ }
1466
+ }
1467
+ /**
1468
+ * Core backstop for every read / aggregate / mutation-filter entry point:
1469
+ * each referenced path (see {@link collectQueryPaths}) must exist on THIS
1470
+ * adapter with the physical capability the position needs (see
1471
+ * {@link guardPath}). Adapters may therefore assume every path they receive
1472
+ * is physical.
1473
+ *
1474
+ * In aggregate mode (`aggregate = true`) `$select` entries are aggregate
1475
+ * expressions whose `$field` is checked, `$groupBy` fields are checked, and
1476
+ * aggregate aliases (`$as` or `fn_field`) are exempt in `$sort` / `$having`.
1477
+ *
1478
+ * Returns the collected refs so callers can run further structural rules
1479
+ * (see {@link checkHavingKeys}) without walking the query again.
1480
+ */
1481
+ function guardPaths(meta, adapter, query, aggregate = false) {
1482
+ if (!query) return;
1483
+ const refs = collectQueryPaths(query, aggregate);
1484
+ if (refs.unsupportedOperator !== void 0) throw pathError(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
1485
+ for (const path of refs.filter) guardPath(meta, adapter, path, "filter");
1486
+ for (const path of refs.geoFilter) guardPath(meta, adapter, path, "filter", true);
1487
+ for (const path of refs.sort) guardPath(meta, adapter, path, "sort");
1488
+ for (const path of refs.select) guardPath(meta, adapter, path, "select");
1489
+ for (const path of refs.aggregate) guardPath(meta, adapter, path, "aggregate");
1490
+ for (const path of refs.groupBy) guardPath(meta, adapter, path, "groupBy");
1491
+ for (const path of refs.having) guardPath(meta, adapter, path, "having");
1492
+ return refs;
1493
+ }
1494
+ /** Shared read-path guard: filter + $sort encryption checks, then the path guard. */
1235
1495
  function guardQuery(meta, adapter, query) {
1236
1496
  if (!query) return;
1237
1497
  guardFilter(meta, adapter, query.filter);
1238
1498
  guardSort(meta, query.controls?.$sort);
1499
+ guardPaths(meta, adapter, query);
1500
+ }
1501
+ /**
1502
+ * `$having` is a post-aggregation filter, so a key is either an aggregate
1503
+ * alias (`$as`, else `fn_field` — already exempt in {@link collectQueryPaths})
1504
+ * or a `$groupBy` field (exact logical-path match: `metadata.clicks` grouped
1505
+ * stays valid). Any other key — a real but non-grouped column included — is
1506
+ * rejected here, once, for SDK and HTTP callers alike, instead of by the
1507
+ * engine (PostgreSQL / MySQL error, SQLite tolerance, Mongo `[]`). Returns
1508
+ * the first offending key as an error entry (`path` = the bare key, as the
1509
+ * `Unknown field` rejection uses); `undefined` when every key is valid.
1510
+ */
1511
+ function checkHavingKeys(refs) {
1512
+ if (refs.having.length === 0) return;
1513
+ const grouped = new Set(refs.groupBy);
1514
+ for (const key of refs.having) if (!grouped.has(key)) return {
1515
+ path: key,
1516
+ message: `$having key "${key}" must be an aggregate alias or a $groupBy field`
1517
+ };
1239
1518
  }
1240
- /** Aggregate-path guard: $groupBy / $select / $having refs + filter + $sort. */
1519
+ /**
1520
+ * Aggregate-path guard: $groupBy / $select / $having encryption refs + filter
1521
+ * + $sort, then the path guard, then the `$having` key rule
1522
+ * ({@link checkHavingKeys} — after the path guard so an unknown key still
1523
+ * reads `Unknown field`).
1524
+ */
1241
1525
  function guardAggregate(meta, adapter, query) {
1242
1526
  guardFilter(meta, adapter, query.filter);
1243
- if (meta.encryptedFields.size === 0) return;
1244
1527
  const controls = query.controls;
1245
- for (const field of controls.$groupBy ?? []) if (isEncryptedRef(meta, field)) throw encryptedRefError("ENC_FIELD_AGG", field, "group by");
1246
- if (controls.$select) for (const item of controls.$select) {
1247
- const field = typeof item === "string" ? item : item.$field;
1248
- if (field !== "*" && isEncryptedRef(meta, field)) throw encryptedRefError("ENC_FIELD_AGG", field, "aggregate over");
1528
+ if (meta.encryptedFields.size > 0) {
1529
+ for (const field of controls.$groupBy ?? []) if (isEncryptedRef(meta, field)) throw encryptedRefError("ENC_FIELD_AGG", field, "group by");
1530
+ if (controls.$select) for (const item of controls.$select) {
1531
+ const field = typeof item === "string" ? item : item.$field;
1532
+ if (field !== "*" && isEncryptedRef(meta, field)) throw encryptedRefError("ENC_FIELD_AGG", field, "aggregate over");
1533
+ }
1534
+ if (controls.$having) guardFilter(meta, adapter, controls.$having, "ENC_FIELD_AGG");
1535
+ guardSort(meta, controls.$sort);
1249
1536
  }
1250
- if (controls.$having) guardFilter(meta, adapter, controls.$having, "ENC_FIELD_AGG");
1251
- guardSort(meta, controls.$sort);
1537
+ const refs = guardPaths(meta, adapter, query, true);
1538
+ const having = refs ? checkHavingKeys(refs) : void 0;
1539
+ if (having) throw new DbError("INVALID_QUERY", [having]);
1252
1540
  }
1253
1541
  //#endregion
1254
1542
  //#region src/table/db-readable.ts
@@ -1761,15 +2049,7 @@ var AtscriptDbReadable = class {
1761
2049
  }
1762
2050
  guardAggregate(this._meta, this.adapter, query);
1763
2051
  const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta);
1764
- return (await this.adapter.aggregate(dbQuery)).map((row) => {
1765
- const mapped = {};
1766
- for (const [key, value] of Object.entries(row)) {
1767
- const logical = this._meta.physicalToPath.get(key) ?? key;
1768
- const fmt = this._meta.fromStorageFormatters?.get(key);
1769
- mapped[logical] = fmt && value !== null && value !== void 0 ? fmt(value) : value;
1770
- }
1771
- return mapped;
1772
- });
2052
+ return (await this.adapter.aggregate(dbQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
1773
2053
  }
1774
2054
  /** Whether the underlying adapter supports text search. */
1775
2055
  isSearchable() {
@@ -2065,7 +2345,7 @@ var AtscriptDbReadable = class {
2065
2345
  * Public entry point for relation loading. Used by adapters for nested $with delegation.
2066
2346
  */
2067
2347
  async loadRelations(rows, withRelations) {
2068
- const { loadRelationsImpl } = await import("./relation-loader-BmeOMj0b.mjs").then((n) => n.n);
2348
+ const { loadRelationsImpl } = await import("./relation-loader-CUGcxJ18.mjs").then((n) => n.n);
2069
2349
  return loadRelationsImpl(rows, withRelations, this);
2070
2350
  }
2071
2351
  /**
@@ -2131,6 +2411,10 @@ function createFailureCollector(what) {
2131
2411
  //#region src/base-adapter.ts
2132
2412
  const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
2133
2413
  const txStorage = new AsyncLocalStorage();
2414
+ /** The innermost open transaction of `owner` in the current async chain. */
2415
+ function findTxContext(owner) {
2416
+ for (let ctx = txStorage.getStore(); ctx; ctx = ctx.parent) if (ctx.owner === owner) return ctx;
2417
+ }
2134
2418
  /**
2135
2419
  * Abstract base class for database adapters.
2136
2420
  *
@@ -2155,6 +2439,15 @@ const txStorage = new AsyncLocalStorage();
2155
2439
  * - `this._table.isView` — whether this is a view (vs a table)
2156
2440
  */
2157
2441
  var BaseDbAdapter = class {
2442
+ /**
2443
+ * The readable this adapter serves. UNSET on an administrative adapter:
2444
+ * `DbSpace` creates one from the factory without a readable for the
2445
+ * name-taking schema-sync primitives (`dropTableByName`, `dropViewByName`,
2446
+ * `dropTablesByName`, `getReferencingForeignKeys`, `getObjectKind`,
2447
+ * `getExistingColumnsForTable`, `hasRows(tableName)`), so those must derive
2448
+ * everything — the schema included — from the driver/connection, never from
2449
+ * `this._table`.
2450
+ */
2158
2451
  _table;
2159
2452
  /**
2160
2453
  * Resolves the correct insertedId: prefers the user-supplied PK value
@@ -2194,15 +2487,22 @@ var BaseDbAdapter = class {
2194
2487
  }
2195
2488
  /**
2196
2489
  * Runs `fn` inside a database transaction. Nested calls (from related tables
2197
- * within the same async chain) reuse the existing transaction automatically.
2490
+ * within the same async chain) reuse the existing transaction automatically
2491
+ * — "existing" meaning a transaction of the same {@link _transactionOwner};
2492
+ * inside another adapter family's transaction this opens its own.
2198
2493
  *
2199
2494
  * The generic layer handles nesting detection via `AsyncLocalStorage`.
2200
2495
  * Adapters override `_beginTransaction`, `_commitTransaction`, and
2201
2496
  * `_rollbackTransaction` to provide raw DB-specific transaction primitives.
2202
2497
  */
2203
2498
  async withTransaction(fn) {
2204
- if (txStorage.getStore()) return fn();
2205
- const ctx = { state: void 0 };
2499
+ const owner = this._transactionOwner();
2500
+ if (findTxContext(owner)) return fn();
2501
+ const ctx = {
2502
+ owner,
2503
+ state: void 0,
2504
+ parent: txStorage.getStore()
2505
+ };
2206
2506
  ctx.state = await this._beginTransaction();
2207
2507
  return txStorage.run(ctx, async () => {
2208
2508
  try {
@@ -2218,22 +2518,39 @@ var BaseDbAdapter = class {
2218
2518
  });
2219
2519
  }
2220
2520
  /**
2221
- * Returns the opaque transaction state from the current async context.
2521
+ * The object a transaction state is branded with (since 0.1.128). Every
2522
+ * adapter instance that returns the same owner shares one transaction —
2523
+ * override to return the driver / pool / client the adapter was constructed
2524
+ * with, so all tables of a space join it. The default (the adapter class)
2525
+ * suits adapters without a connection object (in-memory, mocks).
2526
+ */
2527
+ _transactionOwner() {
2528
+ return this.constructor;
2529
+ }
2530
+ /**
2531
+ * Returns the opaque transaction state of THIS adapter's owner from the
2532
+ * current async context — `undefined` when no transaction is open or only
2533
+ * another adapter family's transaction is (its state is never handed out).
2222
2534
  * Adapters use this to retrieve DB-specific state (e.g., MongoDB `ClientSession`).
2223
2535
  */
2224
2536
  _getTransactionState() {
2225
- return txStorage.getStore()?.state;
2537
+ return findTxContext(this._transactionOwner())?.state;
2226
2538
  }
2227
2539
  /**
2228
2540
  * Runs `fn` inside the transaction ALS context with the given state.
2229
2541
  * Adapters that override `withTransaction` (e.g., to use MongoDB's
2230
2542
  * `session.withTransaction()` Convenient API) use this to set up the
2231
2543
  * shared context so that nested adapters see the same session.
2232
- * If a context already exists (nesting), it's reused.
2544
+ * If a context of the same owner already exists (nesting), it's reused.
2233
2545
  */
2234
2546
  _runInTransactionContext(state, fn) {
2235
- if (txStorage.getStore()) return fn();
2236
- return txStorage.run({ state }, fn);
2547
+ const owner = this._transactionOwner();
2548
+ if (findTxContext(owner)) return fn();
2549
+ return txStorage.run({
2550
+ owner,
2551
+ state,
2552
+ parent: txStorage.getStore()
2553
+ }, fn);
2237
2554
  }
2238
2555
  /**
2239
2556
  * Starts a raw transaction. Returns opaque state stored in the async context.
@@ -2282,11 +2599,14 @@ var BaseDbAdapter = class {
2282
2599
  return false;
2283
2600
  }
2284
2601
  /**
2285
- * Whether the DB engine handles static `@db.default "value"` natively
2286
- * via column-level DEFAULT clauses in CREATE TABLE.
2287
- * When `true`, `_applyDefaults()` skips client-side value defaults,
2288
- * letting the DB apply its own DEFAULT. SQL adapters return `true`;
2289
- * document stores (MongoDB) return `false` and apply defaults client-side.
2602
+ * Whether the DB engine carries static `@db.default "value"` defaults in
2603
+ * its DDL (`DEFAULT` clauses in `CREATE TABLE`).
2604
+ *
2605
+ * @deprecated since 0.1.128 — no longer consulted: the table layer fills
2606
+ * static value defaults SDK-side on every adapter before validation, and the
2607
+ * SQL adapters emit their DDL `DEFAULT` clauses regardless of this flag.
2608
+ * Kept as a capability hint for tooling; nothing in the generic layer
2609
+ * branches on it.
2290
2610
  */
2291
2611
  supportsNativeValueDefaults() {
2292
2612
  return false;
@@ -2332,12 +2652,16 @@ var BaseDbAdapter = class {
2332
2652
  }
2333
2653
  /**
2334
2654
  * Whether this adapter can sort by a given field.
2335
- * Default: scalar columns yes, JSON-stored columns no. Mongo's array sort
2336
- * (min/max element) is a footgun for generic UI sort headers, so the default
2337
- * stays conservative even for adapters that technically support it.
2655
+ * Default: scalar columns yes; JSON-stored columns, `@db.json` objects and
2656
+ * arrays no. Mongo's array sort (min/max element) is a footgun for generic
2657
+ * UI sort headers, so the default stays conservative even for adapters that
2658
+ * technically support it — the veto keys on `designType` as well as
2659
+ * `storage` because nested-object adapters keep arrays / `@db.json` values
2660
+ * inline as `storage: 'column'` (since 0.1.128).
2338
2661
  */
2339
2662
  canSortField(fd) {
2340
2663
  if (fd.encrypted || fd.isGeoPoint) return false;
2664
+ if (fd.designType === "json" || fd.designType === "array") return false;
2341
2665
  return fd.storage !== "json";
2342
2666
  }
2343
2667
  /**
@@ -2387,6 +2711,7 @@ var BaseDbAdapter = class {
2387
2711
  * @param includeSchema - Whether to prepend `schema.` prefix (default: true).
2388
2712
  */
2389
2713
  resolveTableName(includeSchema = true) {
2714
+ if (!this._table) throw new Error("Adapter has no registered readable: table-scoped operations need a table/view; on an administrative adapter use the name-taking primitives (dropTableByName, hasRows(tableName), …)");
2390
2715
  const schema = this._table.schema;
2391
2716
  const name = this._table.tableName;
2392
2717
  return includeSchema && schema ? `${schema}.${name}` : name;
@@ -2428,7 +2753,7 @@ var BaseDbAdapter = class {
2428
2753
  }
2429
2754
  if (index.type === "plain" || index.type === "unique") {
2430
2755
  const liveColumns = existingColumns.get(index.key);
2431
- const desiredColumns = index.fields.map((f) => f.name);
2756
+ const desiredColumns = index.fields.map((f) => opts.renderDesiredColumn?.(index, f) ?? f.name);
2432
2757
  if (liveColumns && (liveColumns.length !== desiredColumns.length || liveColumns.some((c, i) => c !== desiredColumns[i]))) await attempt(`rebuild index "${index.key}"`, async () => {
2433
2758
  await opts.dropIndex(index.key);
2434
2759
  await opts.createIndex(index);
@@ -2576,6 +2901,39 @@ var BaseDbAdapter = class {
2576
2901
  * instead of requiring `@db.sync.method "recreate"` or `"drop"`.
2577
2902
  */
2578
2903
  supportsColumnModify;
2904
+ /**
2905
+ * Drops several tables that reference each other (a foreign-key cycle) as
2906
+ * one operation. Schema sync only calls this for cycles whose members are
2907
+ * ALL being removed. Default: {@link dropTableByName} in the given order —
2908
+ * enough for engines that tolerate it (SQLite with FK checks off, MySQL with
2909
+ * FOREIGN_KEY_CHECKS=0); PostgreSQL overrides it with one multi-table
2910
+ * `DROP TABLE a, b` statement.
2911
+ * @since 0.1.128
2912
+ */
2913
+ async dropTablesByName(tableNames) {
2914
+ for (const name of tableNames) await this.dropTableByName?.(name);
2915
+ }
2916
+ /**
2917
+ * Whether the table has at least one row. Schema sync uses it in the
2918
+ * pre-flight phase to refuse a primary-key change on a populated table.
2919
+ * Override with an EXISTS/LIMIT 1 probe — this default is `count() > 0`,
2920
+ * a full scan on some engines, and it can only answer for the adapter's
2921
+ * OWN table: for another `tableName` (or on an administrative adapter
2922
+ * without a readable) it returns `undefined` ("cannot tell"), which schema
2923
+ * sync treats as a refusal.
2924
+ *
2925
+ * @param tableName - Check this table instead of the adapter's own (schema
2926
+ * sync passes the OLD name of a table that is about to be renamed).
2927
+ * @returns `true`/`false`, or `undefined` when the adapter cannot tell.
2928
+ * @since 0.1.128
2929
+ */
2930
+ async hasRows(tableName) {
2931
+ if (tableName !== void 0 && tableName !== this._table?.tableName) return;
2932
+ return await this.count({
2933
+ filter: {},
2934
+ controls: {}
2935
+ }) > 0;
2936
+ }
2579
2937
  };
2580
2938
  //#endregion
2581
2939
  //#region src/strategies/application-integrity.ts
@@ -3035,11 +3393,104 @@ function _hasOperatorKeys(value) {
3035
3393
  * @typeParam T - The Atscript annotated type for this table.
3036
3394
  * @typeParam DataType - The inferred data shape from the annotated type.
3037
3395
  */
3038
- /** Zero-allocation emptiness check for objects. */
3039
- function _isEmptyObj(obj) {
3040
- for (const _ in obj) return false;
3041
- return true;
3396
+ /**
3397
+ * Clones a write payload while dropping every own key whose value is
3398
+ * `=== undefined` (`undefined` ≡ absent; `null` stays an explicit NULL), so
3399
+ * that defaults, validation, encryption and decomposition never see an
3400
+ * `undefined` prop.
3401
+ *
3402
+ * Recurses into plain objects and into arrays at any depth (plain-object
3403
+ * elements are cloned, elements are never dropped or reordered) and never
3404
+ * into class instances (`Date`, `Uint8Array`/`Buffer`, `ObjectId`, …), which
3405
+ * are kept by reference. Arrays without plain-object elements anywhere below
3406
+ * them are kept by reference too. The caller's payload tree is never mutated.
3407
+ * @internal exported for the core spec only — not part of the package surface.
3408
+ */
3409
+ function _cloneWritePayload(source) {
3410
+ if (typeof source !== "object" || source === null) return { ...source };
3411
+ const out = {};
3412
+ for (const key of Object.keys(source)) {
3413
+ const value = source[key];
3414
+ if (value === void 0) continue;
3415
+ out[key] = _cloneWriteValue(value);
3416
+ }
3417
+ return out;
3042
3418
  }
3419
+ function _cloneWriteValue(value) {
3420
+ if (Array.isArray(value)) {
3421
+ let cloned;
3422
+ for (let i = 0; i < value.length; i++) {
3423
+ const el = value[i];
3424
+ if (isPlainObject(el) || Array.isArray(el)) {
3425
+ const c = _cloneWriteValue(el);
3426
+ if (c !== el) {
3427
+ cloned ??= value.slice();
3428
+ cloned[i] = c;
3429
+ }
3430
+ }
3431
+ }
3432
+ return cloned ?? value;
3433
+ }
3434
+ return isPlainObject(value) ? _cloneWritePayload(value) : value;
3435
+ }
3436
+ /**
3437
+ * The clone for a nested re-entry (`_depth > 0`) and for `preValidateItems`:
3438
+ * the root call already deep-pruned the whole tree, so only the row's own
3439
+ * keys are (re-)pruned — no recursion, no per-level re-cloning of subtrees.
3440
+ */
3441
+ function _shallowPrunedClone(source) {
3442
+ const out = {};
3443
+ for (const key in source) {
3444
+ const value = source[key];
3445
+ if (value !== void 0) out[key] = value;
3446
+ }
3447
+ return out;
3448
+ }
3449
+ /**
3450
+ * {@link TDbWriteGuardContext} handed to a write guard: a sparse per-index
3451
+ * cache of pre-image reads, allocated only when `current(i)` is first used.
3452
+ */
3453
+ var WriteGuardContext = class {
3454
+ action;
3455
+ rows;
3456
+ expectedVersions;
3457
+ _table;
3458
+ _pending;
3459
+ constructor(action, rows, expectedVersions, _table) {
3460
+ this.action = action;
3461
+ this.rows = rows;
3462
+ this.expectedVersions = expectedVersions;
3463
+ this._table = _table;
3464
+ }
3465
+ current(i) {
3466
+ const cache = this._pending ??= [];
3467
+ let pending = cache[i];
3468
+ if (!pending) {
3469
+ pending = this._table._readPreImage(this.rows[i]);
3470
+ cache[i] = pending;
3471
+ }
3472
+ return pending;
3473
+ }
3474
+ };
3475
+ /** {@link TDbRemoveGuardContext} handed to a delete guard (one memoised pre-image read). */
3476
+ var RemoveGuardContext = class {
3477
+ id;
3478
+ filter;
3479
+ _table;
3480
+ _pending;
3481
+ constructor(id, filter, _table) {
3482
+ this.id = id;
3483
+ this.filter = filter;
3484
+ this._table = _table;
3485
+ }
3486
+ current() {
3487
+ this._pending ??= this._table.findOne({
3488
+ filter: this.filter,
3489
+ controls: {}
3490
+ });
3491
+ return this._pending;
3492
+ }
3493
+ };
3043
3494
  /** Translates a single ops record from logical to physical column names. */
3044
3495
  function _translateOpsRecord(rec, meta) {
3045
3496
  const out = {};
@@ -3053,6 +3504,15 @@ function _translateOpsKeys(ops, meta) {
3053
3504
  mul: ops.mul ? _translateOpsRecord(ops.mul, meta) : void 0
3054
3505
  };
3055
3506
  }
3507
+ /** Upper bound of keys per `touchMany` UPDATE statement (parameter-count safety). */
3508
+ const TOUCH_MANY_CHUNK = 500;
3509
+ /** `touchMany` input rejection — always `INVALID_QUERY`, path names the key. */
3510
+ function invalidTouchKey(path, message) {
3511
+ return new DbError("INVALID_QUERY", [{
3512
+ path,
3513
+ message
3514
+ }]);
3515
+ }
3056
3516
  var AtscriptDbTable = class extends AtscriptDbReadable {
3057
3517
  _cascadeResolver;
3058
3518
  _fkLookupResolver;
@@ -3097,7 +3557,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3097
3557
  * nested creation support.
3098
3558
  */
3099
3559
  async insertOne(payload, opts) {
3100
- return { insertedId: (await this.insertMany([payload], opts)).insertedIds[0] };
3560
+ return { insertedId: (await this.insertMany([payload], {
3561
+ ...opts,
3562
+ _action: "insert"
3563
+ })).insertedIds[0] };
3101
3564
  }
3102
3565
  /**
3103
3566
  * Inserts multiple records with batch-optimized nested creation.
@@ -3109,16 +3572,21 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3109
3572
  * (they receive our PKs as their FKs). Fully recursive — nested records
3110
3573
  * with their own nav data trigger further batch inserts at each level.
3111
3574
  * Recursive up to `maxDepth` (default 3).
3575
+ *
3576
+ * `opts.guard` (since 0.1.128) runs once inside the transaction, after
3577
+ * defaults + validation, with the prepared rows — see {@link TWriteOptions}.
3112
3578
  */
3113
3579
  async insertMany(payloads, opts) {
3114
3580
  this._ensureBuilt();
3115
- const { _depth, maxDepth: userMax } = opts ?? {};
3581
+ const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
3116
3582
  const maxDepth = userMax ?? 3;
3117
3583
  const depth = _depth ?? 0;
3118
3584
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
3119
3585
  if (!canNest && this._meta.navFields.size > 0) checkDepthOverflow(payloads, maxDepth, this._meta);
3120
3586
  return enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
3121
- const items = payloads.map((p) => this._applyDefaults({ ...p }));
3587
+ const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
3588
+ const items = payloads.map((p) => this._applyDefaults(clone(p)));
3589
+ const originals = canNest ? items.map((item) => ({ ...item })) : [];
3122
3590
  const validator = this.getValidator("insert");
3123
3591
  const ctx = {
3124
3592
  mode: "insert",
@@ -3126,6 +3594,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3126
3594
  };
3127
3595
  this._applyDepthCtx(ctx, depth);
3128
3596
  validateBatch(validator, items, ctx);
3597
+ if (guard) {
3598
+ await guard(new WriteGuardContext(_action ?? "insertMany", items, Array.from({ length: items.length }), this));
3599
+ validateBatch(validator, items, ctx);
3600
+ }
3129
3601
  await this._encryptItems(items, "write");
3130
3602
  const host = this;
3131
3603
  if (canNest) await batchInsertNestedTo(host, items, maxDepth, depth);
@@ -3135,10 +3607,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3135
3607
  prepared.push(this._fieldMapper.prepareForWrite(data, this._meta, this.adapter));
3136
3608
  }
3137
3609
  await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver);
3138
- if (canNest) await preValidateNestedFrom(host, payloads);
3610
+ if (canNest) await preValidateNestedFrom(host, originals);
3139
3611
  const result = await this.adapter.insertMany(prepared);
3140
- if (canNest) await batchInsertNestedFrom(host, payloads, result.insertedIds, maxDepth, depth);
3141
- if (canNest) await batchInsertNestedVia(host, payloads, result.insertedIds, maxDepth, depth);
3612
+ if (canNest) await batchInsertNestedFrom(host, originals, result.insertedIds, maxDepth, depth);
3613
+ if (canNest) await batchInsertNestedVia(host, originals, result.insertedIds, maxDepth, depth);
3142
3614
  return result;
3143
3615
  }));
3144
3616
  }
@@ -3147,7 +3619,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3147
3619
  * Delegates to {@link bulkReplace} for unified nested relation support.
3148
3620
  */
3149
3621
  async replaceOne(payload, opts) {
3150
- return this.bulkReplace([payload], opts);
3622
+ return this.bulkReplace([payload], {
3623
+ ...opts,
3624
+ _action: "replace"
3625
+ });
3151
3626
  }
3152
3627
  /**
3153
3628
  * Replaces multiple records with deep nested relation support.
@@ -3156,22 +3631,27 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3156
3631
  * replaced first (their PKs become our FKs), FROM dependents are replaced
3157
3632
  * after (they receive our PKs as their FKs), VIA relations clear and
3158
3633
  * re-create junction rows. Fully recursive up to `maxDepth` (default 3).
3634
+ *
3635
+ * `opts.guard` (since 0.1.128) runs once inside the transaction, after
3636
+ * `$cas` extraction, defaults + validation — see {@link TWriteOptions}.
3159
3637
  */
3160
3638
  async bulkReplace(payloads, opts) {
3161
3639
  this._ensureBuilt();
3162
- const maxDepth = opts?.maxDepth ?? 3;
3163
- const depth = opts?._depth ?? 0;
3640
+ const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
3641
+ const maxDepth = userMax ?? 3;
3642
+ const depth = _depth ?? 0;
3164
3643
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
3165
3644
  if (!canNest && this._meta.navFields.size > 0) checkDepthOverflow(payloads, maxDepth, this._meta);
3166
3645
  return enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
3167
3646
  const versionColumn = this.versionColumn;
3168
3647
  const expectedVersions = Array.from({ length: payloads.length });
3648
+ const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
3169
3649
  const items = payloads.map((p, i) => {
3170
- const clone = { ...p };
3171
- expectedVersions[i] = separateCas(clone, versionColumn);
3172
- return this._applyDefaults(clone);
3650
+ const c = clone(p);
3651
+ expectedVersions[i] = separateCas(c, versionColumn);
3652
+ return this._applyDefaults(c);
3173
3653
  });
3174
- const originals = canNest ? payloads.map((p) => ({ ...p })) : [];
3654
+ const originals = canNest ? items.map((item) => ({ ...item })) : [];
3175
3655
  const validator = this.getValidator("bulkReplace");
3176
3656
  const ctx = {
3177
3657
  mode: "replace",
@@ -3179,6 +3659,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3179
3659
  };
3180
3660
  this._applyDepthCtx(ctx, depth);
3181
3661
  validateBatch(validator, items, ctx);
3662
+ if (guard) {
3663
+ await guard(new WriteGuardContext(_action ?? "replaceMany", items, expectedVersions, this));
3664
+ validateBatch(validator, items, ctx);
3665
+ }
3182
3666
  await this._encryptItems(items, "write");
3183
3667
  const host = this;
3184
3668
  if (canNest) await batchReplaceNestedTo(host, items, maxDepth, depth);
@@ -3209,7 +3693,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3209
3693
  * Delegates to {@link bulkUpdate} for unified nested relation support.
3210
3694
  */
3211
3695
  async updateOne(payload, opts) {
3212
- return this.bulkUpdate([payload], opts);
3696
+ return this.bulkUpdate([payload], {
3697
+ ...opts,
3698
+ _action: "update"
3699
+ });
3213
3700
  }
3214
3701
  /**
3215
3702
  * Partially updates multiple records with deep nested relation support.
@@ -3217,18 +3704,24 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3217
3704
  * Only TO relations (1:1, N:1) are supported for patching. FROM/VIA
3218
3705
  * relations will error — use {@link bulkReplace} for those.
3219
3706
  * Recursive up to `maxDepth` (default 3).
3707
+ *
3708
+ * `opts.guard` (since 0.1.128) runs once inside the transaction, after
3709
+ * `$cas` extraction and validation, with the patches (identifying fields
3710
+ * present, `$cas` removed) — see {@link TWriteOptions}.
3220
3711
  */
3221
3712
  async bulkUpdate(payloads, opts) {
3222
3713
  this._ensureBuilt();
3223
- const maxDepth = opts?.maxDepth ?? 3;
3224
- const depth = opts?._depth ?? 0;
3714
+ const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
3715
+ const maxDepth = userMax ?? 3;
3716
+ const depth = _depth ?? 0;
3225
3717
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
3226
3718
  if (!canNest && this._meta.navFields.size > 0) checkDepthOverflow(payloads, maxDepth, this._meta);
3227
3719
  return enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
3228
3720
  const versionColumn = this.versionColumn;
3229
3721
  const expectedVersions = Array.from({ length: payloads.length });
3722
+ const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
3230
3723
  const cloned = payloads.map((p, i) => {
3231
- const c = { ...p };
3724
+ const c = clone(p);
3232
3725
  expectedVersions[i] = separateCas(c, versionColumn);
3233
3726
  return c;
3234
3727
  });
@@ -3240,6 +3733,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3240
3733
  };
3241
3734
  this._applyDepthCtx(ctx, depth);
3242
3735
  validateBatch(validator, cloned, ctx);
3736
+ if (guard) {
3737
+ await guard(new WriteGuardContext(_action ?? "updateMany", cloned, expectedVersions, this));
3738
+ validateBatch(validator, cloned, ctx);
3739
+ }
3243
3740
  const originals = canNest ? cloned.map((p) => ({ ...p })) : [];
3244
3741
  await this._encryptItems(cloned, "patch");
3245
3742
  const host = this;
@@ -3255,13 +3752,16 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3255
3752
  const filter = this._extractRecordFilter(data);
3256
3753
  for (const key of Object.keys(filter)) delete data[key];
3257
3754
  if (versionColumn !== void 0) assertNoVersionWrites(data, versionColumn);
3258
- if (_isEmptyObj(data)) {
3259
- matchedCount += 1;
3260
- modifiedCount += 0;
3755
+ const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
3756
+ if (isEmptyObject(data) && expectedVersion === void 0) {
3757
+ const exists = await this.adapter.count({
3758
+ filter: translatedFilter,
3759
+ controls: {}
3760
+ });
3761
+ matchedCount += exists > 0 ? 1 : 0;
3261
3762
  continue;
3262
3763
  }
3263
3764
  let result;
3264
- const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
3265
3765
  if (this.adapter.supportsNativePatch()) {
3266
3766
  const ops = separateFieldOps(data);
3267
3767
  const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
@@ -3292,27 +3792,114 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3292
3792
  }));
3293
3793
  }
3294
3794
  /**
3795
+ * Batch versioned touch (since 0.1.129): bumps the version of every listed
3796
+ * row by exactly one, each row guarded by its own expected version. This is
3797
+ * the batch fence `updateMany(orFilter, {})` used to be before 0.1.128 (an
3798
+ * empty patch is a no-op since then and takes no lock).
3799
+ *
3800
+ * Each key carries the primary key field(s) (composite supported) plus the
3801
+ * version column and NOTHING else — a touch has no payload. Unique indexes
3802
+ * do not identify a touch key. `undefined`-valued properties are ignored,
3803
+ * like in every write payload. Empty `keys` → `{ 0, 0 }` without a statement.
3804
+ *
3805
+ * `require: 'all'` (default): one count over the whole key set runs FIRST;
3806
+ * a stale or missing row throws {@link CasMismatchError} before any write.
3807
+ * The bumps then run as `updateMany(orFilter, {})` chunks of at most
3808
+ * {@link TOUCH_MANY_CHUNK} keys inside one adapter transaction; a summed
3809
+ * `matchedCount` short of `keys.length` (a row moved between the count and
3810
+ * the bump) throws the same error — SQL engines roll every bump back. The
3811
+ * pre-count is therefore a deliberate double check on SQL: it is what makes
3812
+ * the guarantee hold on adapters whose `withTransaction` is a passthrough
3813
+ * (the memory adapter, a Mongo standalone topology) — there it covers the
3814
+ * common stale case and the residual race window is accepted.
3815
+ * `require: 'any'`: no pre-count, the honest summed result is returned.
3816
+ *
3817
+ * No `guard`, no `onWrite`; not exposed over HTTP.
3818
+ */
3819
+ async touchMany(keys, opts) {
3820
+ this._ensureBuilt();
3821
+ const versionField = this._meta.versionField;
3822
+ if (versionField === void 0) throw invalidTouchKey("", "touchMany requires @db.column.version");
3823
+ if (keys.length === 0) return {
3824
+ matchedCount: 0,
3825
+ modifiedCount: 0
3826
+ };
3827
+ const pkFields = this.primaryKeys;
3828
+ const seen = /* @__PURE__ */ new Set();
3829
+ const pairs = [];
3830
+ for (const [i, key] of keys.entries()) {
3831
+ const pair = {};
3832
+ for (const pk of pkFields) {
3833
+ if (key[pk] === void 0) throw invalidTouchKey(`[${i}].${pk}`, `touchMany: each key must carry its "${pk}"`);
3834
+ pair[pk] = key[pk];
3835
+ }
3836
+ const version = key[versionField];
3837
+ if (typeof version !== "number" || !Number.isFinite(version)) throw invalidTouchKey(`[${i}].${versionField}`, `touchMany: each key must carry its expected "${versionField}" (number)`);
3838
+ for (const [prop, value] of Object.entries(key)) if (value !== void 0 && prop !== versionField && !(prop in pair)) throw invalidTouchKey(`[${i}].${prop}`, `touchMany: a touch carries no payload — keys hold the primary key and "${versionField}" only, got "${prop}"`);
3839
+ const identity = JSON.stringify(pair);
3840
+ if (seen.has(identity)) throw invalidTouchKey(`[${i}]`, `touchMany: duplicate key ${identity}`);
3841
+ seen.add(identity);
3842
+ pair[versionField] = version;
3843
+ pairs.push(pair);
3844
+ }
3845
+ const orFilter = { $or: pairs };
3846
+ this._guardMutationFilter(orFilter);
3847
+ const translated = this._fieldMapper.translateFilter(orFilter, this._meta).$or;
3848
+ const requireAll = (opts?.require ?? "all") === "all";
3849
+ if (requireAll) {
3850
+ const matched = await this.adapter.count({
3851
+ filter: { $or: translated },
3852
+ controls: {}
3853
+ });
3854
+ if (matched < keys.length) throw new CasMismatchError(matched, keys.length);
3855
+ }
3856
+ return this.adapter.withTransaction(async () => {
3857
+ let matchedCount = 0;
3858
+ let modifiedCount = 0;
3859
+ for (let start = 0; start < translated.length; start += TOUCH_MANY_CHUNK) {
3860
+ const chunk = translated.slice(start, start + TOUCH_MANY_CHUNK);
3861
+ const result = await this.adapter.updateMany({ $or: chunk }, {}, void 0);
3862
+ matchedCount += result.matchedCount;
3863
+ modifiedCount += result.modifiedCount;
3864
+ }
3865
+ if (requireAll && matchedCount !== keys.length) throw new CasMismatchError(matchedCount, keys.length);
3866
+ return {
3867
+ matchedCount,
3868
+ modifiedCount
3869
+ };
3870
+ });
3871
+ }
3872
+ /**
3295
3873
  * Deletes a single record by any type-compatible identifier — primary key
3296
3874
  * or single-field unique index. Uses the same resolution logic as `findById`.
3297
3875
  *
3298
3876
  * When the adapter does not support native foreign keys (e.g. MongoDB),
3299
3877
  * cascade and setNull actions are applied before the delete.
3878
+ *
3879
+ * `opts.guard` (since 0.1.128) runs inside the transaction once the id has
3880
+ * resolved to a filter, before cascade / delete — see {@link TDeleteOptions}.
3881
+ * An id that resolves to no filter answers `{ deletedCount: 0 }` without
3882
+ * calling the guard.
3300
3883
  */
3301
- async deleteOne(id) {
3884
+ async deleteOne(id, opts) {
3302
3885
  this._ensureBuilt();
3303
3886
  const filter = this._resolveIdFilter(id);
3304
3887
  if (!filter) return { deletedCount: 0 };
3305
- if (this._integrity.needsCascade(this._cascadeResolver)) return remapDeleteFkViolation(this.tableName, () => this.adapter.withTransaction(async () => {
3306
- await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
3307
- return this.adapter.deleteOne(this._fieldMapper.translateFilter(filter, this._meta));
3308
- }));
3309
- return remapDeleteFkViolation(this.tableName, () => this.adapter.deleteOne(this._fieldMapper.translateFilter(filter, this._meta)));
3888
+ const guard = opts?.guard;
3889
+ const needsCascade = this._integrity.needsCascade(this._cascadeResolver);
3890
+ const translated = this._fieldMapper.translateFilter(filter, this._meta);
3891
+ const run = async () => {
3892
+ if (guard) await guard(new RemoveGuardContext(id, filter, this));
3893
+ if (needsCascade) await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
3894
+ return this.adapter.deleteOne(translated);
3895
+ };
3896
+ return remapDeleteFkViolation(this.tableName, () => guard || needsCascade ? this.adapter.withTransaction(run) : run());
3310
3897
  }
3311
3898
  async updateMany(filter, data) {
3312
3899
  this._ensureBuilt();
3313
3900
  this._guardMutationFilter(filter);
3314
3901
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver, true);
3315
- const dataCopy = { ...data };
3902
+ const dataCopy = _cloneWritePayload(data);
3316
3903
  const versionColumn = this.versionColumn;
3317
3904
  if ("$cas" in dataCopy) throw new DbError("INVALID_QUERY", [{
3318
3905
  path: "$cas",
@@ -3324,13 +3911,21 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3324
3911
  const ops = separateFieldOps(update);
3325
3912
  const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
3326
3913
  const translatedUpdate = this._fieldMapper.translatePatchKeys(update, this._meta);
3327
- return enrichFkViolation(this._meta, () => this.adapter.updateMany(this._fieldMapper.translateFilter(filter, this._meta), translatedUpdate, translatedOps));
3914
+ const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
3915
+ if (translatedOps === void 0 && isEmptyObject(translatedUpdate)) return {
3916
+ matchedCount: await this.adapter.count({
3917
+ filter: translatedFilter,
3918
+ controls: {}
3919
+ }),
3920
+ modifiedCount: 0
3921
+ };
3922
+ return enrichFkViolation(this._meta, () => this.adapter.updateMany(translatedFilter, translatedUpdate, translatedOps));
3328
3923
  }
3329
3924
  async replaceMany(filter, data) {
3330
3925
  this._ensureBuilt();
3331
3926
  this._guardMutationFilter(filter);
3332
3927
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver);
3333
- const dataCopy = { ...data };
3928
+ const dataCopy = _cloneWritePayload(data);
3334
3929
  await this._encryptItems([dataCopy], "write");
3335
3930
  return enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite(dataCopy, this._meta, this.adapter)));
3336
3931
  }
@@ -3360,6 +3955,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3360
3955
  /** Engine-agnostic guard for user-supplied mutation filters (updateMany/deleteMany/…). */
3361
3956
  _guardMutationFilter(filter) {
3362
3957
  guardFilter(this._meta, this.adapter, filter);
3958
+ guardPaths(this._meta, this.adapter, { filter });
3363
3959
  }
3364
3960
  /**
3365
3961
  * Encrypts `@db.encrypted` field values in place on (already validated)
@@ -3389,21 +3985,43 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3389
3985
  }
3390
3986
  }
3391
3987
  /**
3392
- * Applies default values for fields that are missing from the payload.
3393
- * Defaults handled natively by the DB engine are skipped — the field stays
3394
- * absent so the DB's own DEFAULT clause applies.
3988
+ * Lazy pre-image read for a guard's `current(i)`: `null` when the row has
3989
+ * no identifying key (e.g. an auto-increment insert) or the key cannot be
3990
+ * resolved — never throws for a missing key.
3991
+ * @internal
3992
+ */
3993
+ async _readPreImage(row) {
3994
+ if (!row) return null;
3995
+ let filter;
3996
+ try {
3997
+ filter = this._resolveIdFilter(row);
3998
+ } catch {
3999
+ return null;
4000
+ }
4001
+ if (!filter) return null;
4002
+ return await this.findOne({
4003
+ filter,
4004
+ controls: {}
4005
+ });
4006
+ }
4007
+ /**
4008
+ * Applies `@db.default` values in place to a row's absent fields — the
4009
+ * defaults pass every insert / replace path runs before validation.
4010
+ * Static value defaults (`@db.default 'x'`) are filled on EVERY adapter
4011
+ * (since 0.1.128 — writing the column's own default explicitly is
4012
+ * equivalent to leaving it to the DDL `DEFAULT`, and write guards see the
4013
+ * full row). Function defaults (`now` / `uuid` / `increment` / custom) the
4014
+ * adapter handles natively are NOT filled — the field stays absent so the
4015
+ * engine's own default applies. The version column is never touched.
3395
4016
  */
3396
4017
  _applyDefaults(data) {
3397
- const nativeValues = this.adapter.supportsNativeValueDefaults();
3398
4018
  const nativeFns = this.adapter.nativeDefaultFns();
3399
4019
  const versionField = this._meta.versionField;
3400
4020
  for (const [field, def] of this._meta.defaults.entries()) {
3401
4021
  if (field === versionField) continue;
3402
4022
  if (data[field] === void 0) {
3403
- if (def.kind === "value" && !nativeValues) {
3404
- const fieldType = this._meta.flatMap?.get(field);
3405
- data[field] = (fieldType?.type.kind === "" && fieldType.type.designType) === "string" ? def.value : JSON.parse(def.value);
3406
- } else if (def.kind === "fn" && !nativeFns.has(def.fn)) switch (def.fn) {
4023
+ if (def.kind === "value") data[field] = this._parseValueDefault(field, def.value);
4024
+ else if (def.kind === "fn" && !nativeFns.has(def.fn)) switch (def.fn) {
3407
4025
  case "now":
3408
4026
  data[field] = Date.now();
3409
4027
  break;
@@ -3416,6 +4034,22 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3416
4034
  return data;
3417
4035
  }
3418
4036
  /**
4037
+ * The JS value for a `@db.default 'literal'`: strings (including unions of
4038
+ * string literals) are used as-is, every other design type is parsed as
4039
+ * JSON — the same value the SQL adapters put into the DDL `DEFAULT` clause.
4040
+ * A literal that is not valid JSON falls back to the raw string so the
4041
+ * validator reports it against the field instead of a bare `SyntaxError`.
4042
+ */
4043
+ _parseValueDefault(field, literal) {
4044
+ const fieldType = this._meta.flatMap?.get(field);
4045
+ if ((fieldType ? resolveDesignType(fieldType) : "string") === "string") return literal;
4046
+ try {
4047
+ return JSON.parse(literal);
4048
+ } catch {
4049
+ return literal;
4050
+ }
4051
+ }
4052
+ /**
3419
4053
  * Extracts a record-identifying filter from a payload.
3420
4054
  *
3421
4055
  * Resolution order:
@@ -3521,7 +4155,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3521
4155
  mode: "insert",
3522
4156
  navFields: this._meta.navFields
3523
4157
  };
3524
- validateBatch(validator, items.map((raw) => this._applyDefaults({ ...raw })), ctx);
4158
+ validateBatch(validator, items.map((raw) => this._applyDefaults(_shallowPrunedClone(raw))), ctx);
3525
4159
  await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver, false, opts?.excludeFkTargetTable);
3526
4160
  }
3527
4161
  /**
@@ -3535,22 +4169,6 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3535
4169
  const adapterPlugins = this.adapter.getValidatorPlugins();
3536
4170
  if (purpose === "insert" || purpose === "patch" || purpose === "bulkReplace") {
3537
4171
  const mode = purpose === "bulkReplace" ? "replace" : purpose;
3538
- const versionField = this._meta.versionField;
3539
- if (versionField !== void 0) {
3540
- const plugins = adapterPlugins.length ? [...adapterPlugins, dbPlugin] : [dbPlugin];
3541
- return this.createValidator({
3542
- plugins,
3543
- partial: mode === "patch" ? buildPatchPartial(this._meta.navFields) : false,
3544
- replace: (def, path) => {
3545
- const transformed = forceNavNonOptional(def);
3546
- if (path === versionField && !transformed.optional) return {
3547
- ...transformed,
3548
- optional: true
3549
- };
3550
- return transformed;
3551
- }
3552
- });
3553
- }
3554
4172
  return buildDbValidator(this.type, mode, adapterPlugins);
3555
4173
  }
3556
4174
  if (purpose === "bulkUpdate") {
@@ -3664,7 +4282,9 @@ var AtscriptDbView = class extends AtscriptDbReadable {
3664
4282
  "db.agg.min",
3665
4283
  "db.agg.max"
3666
4284
  ];
4285
+ const ignored = this.ignoredFields;
3667
4286
  for (const [fieldName, fieldType] of this._type.type.props.entries()) {
4287
+ if (ignored.has(fieldName)) continue;
3668
4288
  let aggFn;
3669
4289
  let aggField;
3670
4290
  for (const key of aggKeys) {
@@ -3700,5 +4320,17 @@ var AtscriptDbView = class extends AtscriptDbReadable {
3700
4320
  return mappings;
3701
4321
  }
3702
4322
  };
4323
+ /**
4324
+ * Structural type guard for views: `true` when the readable reports
4325
+ * `isView`, whether or not it is an `AtscriptDbView` instance of THIS copy
4326
+ * of `@atscript/db`. Adapters must use this (or `readable.isView`) instead of
4327
+ * `instanceof AtscriptDbView` — in a bundle that carries two copies of the
4328
+ * core (app bundle + external adapter), `instanceof` is false and the adapter
4329
+ * would create an empty physical table under the view's name.
4330
+ * @since 0.1.128
4331
+ */
4332
+ function isAtscriptDbView(readable) {
4333
+ return readable.isView;
4334
+ }
3703
4335
  //#endregion
3704
- export { NoopLogger as C, isGeoPointType as S, DocumentFieldMapper as _, ApplicationIntegrity as a, TableMetadata as b, IntegrityStrategy as c, resolveDesignType as d, assertGeoPoint as f, RelationalFieldMapper as g, guardQuery as h, decomposePatch as i, NativeIntegrity as l, guardFilter as m, AtscriptDbTable as n, BaseDbAdapter as o, guardAggregate as p, assertNoVersionWrites as r, createFailureCollector as s, AtscriptDbView as t, AtscriptDbReadable as u, FieldMappingStrategy as v, isGeoIndexableType as x, UniquSelect as y };
4336
+ export { isGeoIndexableType as A, unsupportedOperatorMessage as C, UniquSelect as D, FieldMappingStrategy as E, NoopLogger as M, TableMetadata as O, sortFieldNames as S, DocumentFieldMapper as T, guardAggregate as _, decomposePatch as a, guardPaths as b, createFailureCollector as c, AtscriptDbReadable as d, resolveDesignType as f, collectQueryPaths as g, classifyQueryPath as h, assertNoVersionWrites as i, isGeoPointType as j, findAncestorInSet as k, IntegrityStrategy as l, checkHavingKeys as m, isAtscriptDbView as n, ApplicationIntegrity as o, assertGeoPoint as p, AtscriptDbTable as r, BaseDbAdapter as s, AtscriptDbView as t, NativeIntegrity as u, guardFilter as v, RelationalFieldMapper as w, guardQuery as x, guardPath as y };