@atscript/db 0.1.127 → 0.1.128

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 (33) hide show
  1. package/dist/{db-readable-BkAGccv9.d.mts → db-readable-BTl4NzNN.d.mts} +289 -16
  2. package/dist/{db-readable-C0nDKX8A.d.cts → db-readable-CkbZn_z-.d.cts} +289 -16
  3. package/dist/{db-space-B_ASuDaR.d.mts → db-space-BEB5Jl8M.d.mts} +81 -29
  4. package/dist/{db-space-CSntT6yS.d.cts → db-space-BevOyVrC.d.cts} +81 -29
  5. package/dist/{db-view-BP0Qbeux.cjs → db-view-C8-MsjND.cjs} +696 -97
  6. package/dist/{db-view-C8rZM5_N.mjs → db-view-Ck0I-PMI.mjs} +643 -98
  7. package/dist/index.cjs +46 -2
  8. package/dist/index.d.cts +162 -37
  9. package/dist/index.d.mts +162 -37
  10. package/dist/index.mjs +36 -4
  11. package/dist/{ops-DJRnNTVo.d.cts → ops-AqhV7s9o.d.cts} +24 -1
  12. package/dist/{ops-DJRnNTVo.d.mts → ops-AqhV7s9o.d.mts} +24 -1
  13. package/dist/ops.cjs +43 -0
  14. package/dist/ops.d.cts +2 -2
  15. package/dist/ops.d.mts +2 -2
  16. package/dist/ops.mjs +43 -1
  17. package/dist/plugin.cjs +12 -5
  18. package/dist/plugin.mjs +12 -5
  19. package/dist/rel.d.cts +1 -1
  20. package/dist/rel.d.mts +1 -1
  21. package/dist/sync.cjs +1096 -361
  22. package/dist/sync.d.cts +234 -15
  23. package/dist/sync.d.mts +234 -15
  24. package/dist/sync.mjs +1095 -362
  25. package/dist/{validator-CSGug4vg.cjs → validator-BNUHCXIE.cjs} +31 -2
  26. package/dist/{validator-BcBtg8yW.d.cts → validator-DuAmWc8L.d.cts} +38 -1
  27. package/dist/{validator-BcBtg8yW.d.mts → validator-DuAmWc8L.d.mts} +38 -1
  28. package/dist/{validator-0vRXN51D.mjs → validator-eKYWf3xf.mjs} +20 -3
  29. package/dist/validator.cjs +7 -1
  30. package/dist/validator.d.cts +3 -3
  31. package/dist/validator.d.mts +3 -3
  32. package/dist/validator.mjs +4 -3
  33. package/package.json +8 -8
@@ -1,8 +1,8 @@
1
1
  const require_db_error = require("./db-error-DXwEzmYJ.cjs");
2
+ const require_validator = require("./validator-BNUHCXIE.cjs");
2
3
  const require_agg = require("./agg.cjs");
3
4
  const require_nested_writer = require("./nested-writer-DoDhl3X3.cjs");
4
5
  const require_ops = require("./ops.cjs");
5
- const require_validator = require("./validator-CSGug4vg.cjs");
6
6
  let _atscript_typescript_utils = require("@atscript/typescript/utils");
7
7
  let node_async_hooks = require("node:async_hooks");
8
8
  //#region src/logger.ts
@@ -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 (!require_validator.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 require_db_error.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(require_agg.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 require_db_error.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() {
@@ -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 node_async_hooks.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 node_async_hooks.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 (require_validator.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 require_validator.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 = {};
@@ -3097,7 +3548,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3097
3548
  * nested creation support.
3098
3549
  */
3099
3550
  async insertOne(payload, opts) {
3100
- return { insertedId: (await this.insertMany([payload], opts)).insertedIds[0] };
3551
+ return { insertedId: (await this.insertMany([payload], {
3552
+ ...opts,
3553
+ _action: "insert"
3554
+ })).insertedIds[0] };
3101
3555
  }
3102
3556
  /**
3103
3557
  * Inserts multiple records with batch-optimized nested creation.
@@ -3109,16 +3563,21 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3109
3563
  * (they receive our PKs as their FKs). Fully recursive — nested records
3110
3564
  * with their own nav data trigger further batch inserts at each level.
3111
3565
  * Recursive up to `maxDepth` (default 3).
3566
+ *
3567
+ * `opts.guard` (since 0.1.128) runs once inside the transaction, after
3568
+ * defaults + validation, with the prepared rows — see {@link TWriteOptions}.
3112
3569
  */
3113
3570
  async insertMany(payloads, opts) {
3114
3571
  this._ensureBuilt();
3115
- const { _depth, maxDepth: userMax } = opts ?? {};
3572
+ const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
3116
3573
  const maxDepth = userMax ?? 3;
3117
3574
  const depth = _depth ?? 0;
3118
3575
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
3119
3576
  if (!canNest && this._meta.navFields.size > 0) require_nested_writer.checkDepthOverflow(payloads, maxDepth, this._meta);
3120
3577
  return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
3121
- const items = payloads.map((p) => this._applyDefaults({ ...p }));
3578
+ const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
3579
+ const items = payloads.map((p) => this._applyDefaults(clone(p)));
3580
+ const originals = canNest ? items.map((item) => ({ ...item })) : [];
3122
3581
  const validator = this.getValidator("insert");
3123
3582
  const ctx = {
3124
3583
  mode: "insert",
@@ -3126,6 +3585,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3126
3585
  };
3127
3586
  this._applyDepthCtx(ctx, depth);
3128
3587
  require_nested_writer.validateBatch(validator, items, ctx);
3588
+ if (guard) {
3589
+ await guard(new WriteGuardContext(_action ?? "insertMany", items, Array.from({ length: items.length }), this));
3590
+ require_nested_writer.validateBatch(validator, items, ctx);
3591
+ }
3129
3592
  await this._encryptItems(items, "write");
3130
3593
  const host = this;
3131
3594
  if (canNest) await require_nested_writer.batchInsertNestedTo(host, items, maxDepth, depth);
@@ -3135,10 +3598,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3135
3598
  prepared.push(this._fieldMapper.prepareForWrite(data, this._meta, this.adapter));
3136
3599
  }
3137
3600
  await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver);
3138
- if (canNest) await require_nested_writer.preValidateNestedFrom(host, payloads);
3601
+ if (canNest) await require_nested_writer.preValidateNestedFrom(host, originals);
3139
3602
  const result = await this.adapter.insertMany(prepared);
3140
- if (canNest) await require_nested_writer.batchInsertNestedFrom(host, payloads, result.insertedIds, maxDepth, depth);
3141
- if (canNest) await require_nested_writer.batchInsertNestedVia(host, payloads, result.insertedIds, maxDepth, depth);
3603
+ if (canNest) await require_nested_writer.batchInsertNestedFrom(host, originals, result.insertedIds, maxDepth, depth);
3604
+ if (canNest) await require_nested_writer.batchInsertNestedVia(host, originals, result.insertedIds, maxDepth, depth);
3142
3605
  return result;
3143
3606
  }));
3144
3607
  }
@@ -3147,7 +3610,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3147
3610
  * Delegates to {@link bulkReplace} for unified nested relation support.
3148
3611
  */
3149
3612
  async replaceOne(payload, opts) {
3150
- return this.bulkReplace([payload], opts);
3613
+ return this.bulkReplace([payload], {
3614
+ ...opts,
3615
+ _action: "replace"
3616
+ });
3151
3617
  }
3152
3618
  /**
3153
3619
  * Replaces multiple records with deep nested relation support.
@@ -3156,22 +3622,27 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3156
3622
  * replaced first (their PKs become our FKs), FROM dependents are replaced
3157
3623
  * after (they receive our PKs as their FKs), VIA relations clear and
3158
3624
  * re-create junction rows. Fully recursive up to `maxDepth` (default 3).
3625
+ *
3626
+ * `opts.guard` (since 0.1.128) runs once inside the transaction, after
3627
+ * `$cas` extraction, defaults + validation — see {@link TWriteOptions}.
3159
3628
  */
3160
3629
  async bulkReplace(payloads, opts) {
3161
3630
  this._ensureBuilt();
3162
- const maxDepth = opts?.maxDepth ?? 3;
3163
- const depth = opts?._depth ?? 0;
3631
+ const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
3632
+ const maxDepth = userMax ?? 3;
3633
+ const depth = _depth ?? 0;
3164
3634
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
3165
3635
  if (!canNest && this._meta.navFields.size > 0) require_nested_writer.checkDepthOverflow(payloads, maxDepth, this._meta);
3166
3636
  return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
3167
3637
  const versionColumn = this.versionColumn;
3168
3638
  const expectedVersions = Array.from({ length: payloads.length });
3639
+ const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
3169
3640
  const items = payloads.map((p, i) => {
3170
- const clone = { ...p };
3171
- expectedVersions[i] = require_ops.separateCas(clone, versionColumn);
3172
- return this._applyDefaults(clone);
3641
+ const c = clone(p);
3642
+ expectedVersions[i] = require_ops.separateCas(c, versionColumn);
3643
+ return this._applyDefaults(c);
3173
3644
  });
3174
- const originals = canNest ? payloads.map((p) => ({ ...p })) : [];
3645
+ const originals = canNest ? items.map((item) => ({ ...item })) : [];
3175
3646
  const validator = this.getValidator("bulkReplace");
3176
3647
  const ctx = {
3177
3648
  mode: "replace",
@@ -3179,6 +3650,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3179
3650
  };
3180
3651
  this._applyDepthCtx(ctx, depth);
3181
3652
  require_nested_writer.validateBatch(validator, items, ctx);
3653
+ if (guard) {
3654
+ await guard(new WriteGuardContext(_action ?? "replaceMany", items, expectedVersions, this));
3655
+ require_nested_writer.validateBatch(validator, items, ctx);
3656
+ }
3182
3657
  await this._encryptItems(items, "write");
3183
3658
  const host = this;
3184
3659
  if (canNest) await require_nested_writer.batchReplaceNestedTo(host, items, maxDepth, depth);
@@ -3209,7 +3684,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3209
3684
  * Delegates to {@link bulkUpdate} for unified nested relation support.
3210
3685
  */
3211
3686
  async updateOne(payload, opts) {
3212
- return this.bulkUpdate([payload], opts);
3687
+ return this.bulkUpdate([payload], {
3688
+ ...opts,
3689
+ _action: "update"
3690
+ });
3213
3691
  }
3214
3692
  /**
3215
3693
  * Partially updates multiple records with deep nested relation support.
@@ -3217,18 +3695,24 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3217
3695
  * Only TO relations (1:1, N:1) are supported for patching. FROM/VIA
3218
3696
  * relations will error — use {@link bulkReplace} for those.
3219
3697
  * Recursive up to `maxDepth` (default 3).
3698
+ *
3699
+ * `opts.guard` (since 0.1.128) runs once inside the transaction, after
3700
+ * `$cas` extraction and validation, with the patches (identifying fields
3701
+ * present, `$cas` removed) — see {@link TWriteOptions}.
3220
3702
  */
3221
3703
  async bulkUpdate(payloads, opts) {
3222
3704
  this._ensureBuilt();
3223
- const maxDepth = opts?.maxDepth ?? 3;
3224
- const depth = opts?._depth ?? 0;
3705
+ const { _depth, _action, maxDepth: userMax, guard } = opts ?? {};
3706
+ const maxDepth = userMax ?? 3;
3707
+ const depth = _depth ?? 0;
3225
3708
  const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
3226
3709
  if (!canNest && this._meta.navFields.size > 0) require_nested_writer.checkDepthOverflow(payloads, maxDepth, this._meta);
3227
3710
  return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
3228
3711
  const versionColumn = this.versionColumn;
3229
3712
  const expectedVersions = Array.from({ length: payloads.length });
3713
+ const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
3230
3714
  const cloned = payloads.map((p, i) => {
3231
- const c = { ...p };
3715
+ const c = clone(p);
3232
3716
  expectedVersions[i] = require_ops.separateCas(c, versionColumn);
3233
3717
  return c;
3234
3718
  });
@@ -3240,6 +3724,10 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3240
3724
  };
3241
3725
  this._applyDepthCtx(ctx, depth);
3242
3726
  require_nested_writer.validateBatch(validator, cloned, ctx);
3727
+ if (guard) {
3728
+ await guard(new WriteGuardContext(_action ?? "updateMany", cloned, expectedVersions, this));
3729
+ require_nested_writer.validateBatch(validator, cloned, ctx);
3730
+ }
3243
3731
  const originals = canNest ? cloned.map((p) => ({ ...p })) : [];
3244
3732
  await this._encryptItems(cloned, "patch");
3245
3733
  const host = this;
@@ -3255,13 +3743,16 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3255
3743
  const filter = this._extractRecordFilter(data);
3256
3744
  for (const key of Object.keys(filter)) delete data[key];
3257
3745
  if (versionColumn !== void 0) assertNoVersionWrites(data, versionColumn);
3258
- if (_isEmptyObj(data)) {
3259
- matchedCount += 1;
3260
- modifiedCount += 0;
3746
+ const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
3747
+ if (require_validator.isEmptyObject(data) && expectedVersion === void 0) {
3748
+ const exists = await this.adapter.count({
3749
+ filter: translatedFilter,
3750
+ controls: {}
3751
+ });
3752
+ matchedCount += exists > 0 ? 1 : 0;
3261
3753
  continue;
3262
3754
  }
3263
3755
  let result;
3264
- const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
3265
3756
  if (this.adapter.supportsNativePatch()) {
3266
3757
  const ops = require_ops.separateFieldOps(data);
3267
3758
  const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
@@ -3297,22 +3788,31 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3297
3788
  *
3298
3789
  * When the adapter does not support native foreign keys (e.g. MongoDB),
3299
3790
  * cascade and setNull actions are applied before the delete.
3791
+ *
3792
+ * `opts.guard` (since 0.1.128) runs inside the transaction once the id has
3793
+ * resolved to a filter, before cascade / delete — see {@link TDeleteOptions}.
3794
+ * An id that resolves to no filter answers `{ deletedCount: 0 }` without
3795
+ * calling the guard.
3300
3796
  */
3301
- async deleteOne(id) {
3797
+ async deleteOne(id, opts) {
3302
3798
  this._ensureBuilt();
3303
3799
  const filter = this._resolveIdFilter(id);
3304
3800
  if (!filter) return { deletedCount: 0 };
3305
- if (this._integrity.needsCascade(this._cascadeResolver)) return require_nested_writer.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 require_nested_writer.remapDeleteFkViolation(this.tableName, () => this.adapter.deleteOne(this._fieldMapper.translateFilter(filter, this._meta)));
3801
+ const guard = opts?.guard;
3802
+ const needsCascade = this._integrity.needsCascade(this._cascadeResolver);
3803
+ const translated = this._fieldMapper.translateFilter(filter, this._meta);
3804
+ const run = async () => {
3805
+ if (guard) await guard(new RemoveGuardContext(id, filter, this));
3806
+ if (needsCascade) await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
3807
+ return this.adapter.deleteOne(translated);
3808
+ };
3809
+ return require_nested_writer.remapDeleteFkViolation(this.tableName, () => guard || needsCascade ? this.adapter.withTransaction(run) : run());
3310
3810
  }
3311
3811
  async updateMany(filter, data) {
3312
3812
  this._ensureBuilt();
3313
3813
  this._guardMutationFilter(filter);
3314
3814
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver, true);
3315
- const dataCopy = { ...data };
3815
+ const dataCopy = _cloneWritePayload(data);
3316
3816
  const versionColumn = this.versionColumn;
3317
3817
  if ("$cas" in dataCopy) throw new require_db_error.DbError("INVALID_QUERY", [{
3318
3818
  path: "$cas",
@@ -3324,13 +3824,21 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3324
3824
  const ops = require_ops.separateFieldOps(update);
3325
3825
  const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
3326
3826
  const translatedUpdate = this._fieldMapper.translatePatchKeys(update, this._meta);
3327
- return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.updateMany(this._fieldMapper.translateFilter(filter, this._meta), translatedUpdate, translatedOps));
3827
+ const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
3828
+ if (translatedOps === void 0 && require_validator.isEmptyObject(translatedUpdate)) return {
3829
+ matchedCount: await this.adapter.count({
3830
+ filter: translatedFilter,
3831
+ controls: {}
3832
+ }),
3833
+ modifiedCount: 0
3834
+ };
3835
+ return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.updateMany(translatedFilter, translatedUpdate, translatedOps));
3328
3836
  }
3329
3837
  async replaceMany(filter, data) {
3330
3838
  this._ensureBuilt();
3331
3839
  this._guardMutationFilter(filter);
3332
3840
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver);
3333
- const dataCopy = { ...data };
3841
+ const dataCopy = _cloneWritePayload(data);
3334
3842
  await this._encryptItems([dataCopy], "write");
3335
3843
  return require_nested_writer.enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite(dataCopy, this._meta, this.adapter)));
3336
3844
  }
@@ -3360,6 +3868,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3360
3868
  /** Engine-agnostic guard for user-supplied mutation filters (updateMany/deleteMany/…). */
3361
3869
  _guardMutationFilter(filter) {
3362
3870
  guardFilter(this._meta, this.adapter, filter);
3871
+ guardPaths(this._meta, this.adapter, { filter });
3363
3872
  }
3364
3873
  /**
3365
3874
  * Encrypts `@db.encrypted` field values in place on (already validated)
@@ -3389,21 +3898,43 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3389
3898
  }
3390
3899
  }
3391
3900
  /**
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.
3901
+ * Lazy pre-image read for a guard's `current(i)`: `null` when the row has
3902
+ * no identifying key (e.g. an auto-increment insert) or the key cannot be
3903
+ * resolved — never throws for a missing key.
3904
+ * @internal
3905
+ */
3906
+ async _readPreImage(row) {
3907
+ if (!row) return null;
3908
+ let filter;
3909
+ try {
3910
+ filter = this._resolveIdFilter(row);
3911
+ } catch {
3912
+ return null;
3913
+ }
3914
+ if (!filter) return null;
3915
+ return await this.findOne({
3916
+ filter,
3917
+ controls: {}
3918
+ });
3919
+ }
3920
+ /**
3921
+ * Applies `@db.default` values in place to a row's absent fields — the
3922
+ * defaults pass every insert / replace path runs before validation.
3923
+ * Static value defaults (`@db.default 'x'`) are filled on EVERY adapter
3924
+ * (since 0.1.128 — writing the column's own default explicitly is
3925
+ * equivalent to leaving it to the DDL `DEFAULT`, and write guards see the
3926
+ * full row). Function defaults (`now` / `uuid` / `increment` / custom) the
3927
+ * adapter handles natively are NOT filled — the field stays absent so the
3928
+ * engine's own default applies. The version column is never touched.
3395
3929
  */
3396
3930
  _applyDefaults(data) {
3397
- const nativeValues = this.adapter.supportsNativeValueDefaults();
3398
3931
  const nativeFns = this.adapter.nativeDefaultFns();
3399
3932
  const versionField = this._meta.versionField;
3400
3933
  for (const [field, def] of this._meta.defaults.entries()) {
3401
3934
  if (field === versionField) continue;
3402
3935
  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) {
3936
+ if (def.kind === "value") data[field] = this._parseValueDefault(field, def.value);
3937
+ else if (def.kind === "fn" && !nativeFns.has(def.fn)) switch (def.fn) {
3407
3938
  case "now":
3408
3939
  data[field] = Date.now();
3409
3940
  break;
@@ -3416,6 +3947,22 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3416
3947
  return data;
3417
3948
  }
3418
3949
  /**
3950
+ * The JS value for a `@db.default 'literal'`: strings (including unions of
3951
+ * string literals) are used as-is, every other design type is parsed as
3952
+ * JSON — the same value the SQL adapters put into the DDL `DEFAULT` clause.
3953
+ * A literal that is not valid JSON falls back to the raw string so the
3954
+ * validator reports it against the field instead of a bare `SyntaxError`.
3955
+ */
3956
+ _parseValueDefault(field, literal) {
3957
+ const fieldType = this._meta.flatMap?.get(field);
3958
+ if ((fieldType ? resolveDesignType(fieldType) : "string") === "string") return literal;
3959
+ try {
3960
+ return JSON.parse(literal);
3961
+ } catch {
3962
+ return literal;
3963
+ }
3964
+ }
3965
+ /**
3419
3966
  * Extracts a record-identifying filter from a payload.
3420
3967
  *
3421
3968
  * Resolution order:
@@ -3521,7 +4068,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3521
4068
  mode: "insert",
3522
4069
  navFields: this._meta.navFields
3523
4070
  };
3524
- require_nested_writer.validateBatch(validator, items.map((raw) => this._applyDefaults({ ...raw })), ctx);
4071
+ require_nested_writer.validateBatch(validator, items.map((raw) => this._applyDefaults(_shallowPrunedClone(raw))), ctx);
3525
4072
  await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver, false, opts?.excludeFkTargetTable);
3526
4073
  }
3527
4074
  /**
@@ -3535,22 +4082,6 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
3535
4082
  const adapterPlugins = this.adapter.getValidatorPlugins();
3536
4083
  if (purpose === "insert" || purpose === "patch" || purpose === "bulkReplace") {
3537
4084
  const mode = purpose === "bulkReplace" ? "replace" : purpose;
3538
- const versionField = this._meta.versionField;
3539
- if (versionField !== void 0) {
3540
- const plugins = adapterPlugins.length ? [...adapterPlugins, require_validator.dbPlugin] : [require_validator.dbPlugin];
3541
- return this.createValidator({
3542
- plugins,
3543
- partial: mode === "patch" ? require_validator.buildPatchPartial(this._meta.navFields) : false,
3544
- replace: (def, path) => {
3545
- const transformed = require_validator.forceNavNonOptional(def);
3546
- if (path === versionField && !transformed.optional) return {
3547
- ...transformed,
3548
- optional: true
3549
- };
3550
- return transformed;
3551
- }
3552
- });
3553
- }
3554
4085
  return require_validator.buildDbValidator(this.type, mode, adapterPlugins);
3555
4086
  }
3556
4087
  if (purpose === "bulkUpdate") {
@@ -3664,7 +4195,9 @@ var AtscriptDbView = class extends AtscriptDbReadable {
3664
4195
  "db.agg.min",
3665
4196
  "db.agg.max"
3666
4197
  ];
4198
+ const ignored = this.ignoredFields;
3667
4199
  for (const [fieldName, fieldType] of this._type.type.props.entries()) {
4200
+ if (ignored.has(fieldName)) continue;
3668
4201
  let aggFn;
3669
4202
  let aggField;
3670
4203
  for (const key of aggKeys) {
@@ -3700,6 +4233,18 @@ var AtscriptDbView = class extends AtscriptDbReadable {
3700
4233
  return mappings;
3701
4234
  }
3702
4235
  };
4236
+ /**
4237
+ * Structural type guard for views: `true` when the readable reports
4238
+ * `isView`, whether or not it is an `AtscriptDbView` instance of THIS copy
4239
+ * of `@atscript/db`. Adapters must use this (or `readable.isView`) instead of
4240
+ * `instanceof AtscriptDbView` — in a bundle that carries two copies of the
4241
+ * core (app bundle + external adapter), `instanceof` is false and the adapter
4242
+ * would create an empty physical table under the view's name.
4243
+ * @since 0.1.128
4244
+ */
4245
+ function isAtscriptDbView(readable) {
4246
+ return readable.isView;
4247
+ }
3703
4248
  //#endregion
3704
4249
  Object.defineProperty(exports, "ApplicationIntegrity", {
3705
4250
  enumerable: true,
@@ -3791,6 +4336,24 @@ Object.defineProperty(exports, "assertNoVersionWrites", {
3791
4336
  return assertNoVersionWrites;
3792
4337
  }
3793
4338
  });
4339
+ Object.defineProperty(exports, "checkHavingKeys", {
4340
+ enumerable: true,
4341
+ get: function() {
4342
+ return checkHavingKeys;
4343
+ }
4344
+ });
4345
+ Object.defineProperty(exports, "classifyQueryPath", {
4346
+ enumerable: true,
4347
+ get: function() {
4348
+ return classifyQueryPath;
4349
+ }
4350
+ });
4351
+ Object.defineProperty(exports, "collectQueryPaths", {
4352
+ enumerable: true,
4353
+ get: function() {
4354
+ return collectQueryPaths;
4355
+ }
4356
+ });
3794
4357
  Object.defineProperty(exports, "createFailureCollector", {
3795
4358
  enumerable: true,
3796
4359
  get: function() {
@@ -3803,6 +4366,12 @@ Object.defineProperty(exports, "decomposePatch", {
3803
4366
  return decomposePatch;
3804
4367
  }
3805
4368
  });
4369
+ Object.defineProperty(exports, "findAncestorInSet", {
4370
+ enumerable: true,
4371
+ get: function() {
4372
+ return findAncestorInSet;
4373
+ }
4374
+ });
3806
4375
  Object.defineProperty(exports, "guardAggregate", {
3807
4376
  enumerable: true,
3808
4377
  get: function() {
@@ -3815,12 +4384,30 @@ Object.defineProperty(exports, "guardFilter", {
3815
4384
  return guardFilter;
3816
4385
  }
3817
4386
  });
4387
+ Object.defineProperty(exports, "guardPath", {
4388
+ enumerable: true,
4389
+ get: function() {
4390
+ return guardPath;
4391
+ }
4392
+ });
4393
+ Object.defineProperty(exports, "guardPaths", {
4394
+ enumerable: true,
4395
+ get: function() {
4396
+ return guardPaths;
4397
+ }
4398
+ });
3818
4399
  Object.defineProperty(exports, "guardQuery", {
3819
4400
  enumerable: true,
3820
4401
  get: function() {
3821
4402
  return guardQuery;
3822
4403
  }
3823
4404
  });
4405
+ Object.defineProperty(exports, "isAtscriptDbView", {
4406
+ enumerable: true,
4407
+ get: function() {
4408
+ return isAtscriptDbView;
4409
+ }
4410
+ });
3824
4411
  Object.defineProperty(exports, "isGeoIndexableType", {
3825
4412
  enumerable: true,
3826
4413
  get: function() {
@@ -3839,3 +4426,15 @@ Object.defineProperty(exports, "resolveDesignType", {
3839
4426
  return resolveDesignType;
3840
4427
  }
3841
4428
  });
4429
+ Object.defineProperty(exports, "sortFieldNames", {
4430
+ enumerable: true,
4431
+ get: function() {
4432
+ return sortFieldNames;
4433
+ }
4434
+ });
4435
+ Object.defineProperty(exports, "unsupportedOperatorMessage", {
4436
+ enumerable: true,
4437
+ get: function() {
4438
+ return unsupportedOperatorMessage;
4439
+ }
4440
+ });