@atscript/db 0.1.154 → 0.1.156

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 (35) hide show
  1. package/dist/agg.d.cts +1 -1
  2. package/dist/agg.d.mts +1 -1
  3. package/dist/{buckets-CBKL_ZWE.d.mts → buckets-8mG67Mtw.d.mts} +168 -16
  4. package/dist/{buckets-jy8BfswO.d.cts → buckets-B4T_alUe.d.cts} +168 -16
  5. package/dist/{column-diff-DAIAq6XR.mjs → column-diff-DR1glykD.mjs} +419 -75
  6. package/dist/{column-diff-qnzx1o3c.cjs → column-diff-DpeUbhEI.cjs} +430 -74
  7. package/dist/{fk-diff-BIJ8l_hF.d.mts → fk-diff-5gX42hBt.d.mts} +2 -1
  8. package/dist/{fk-diff-D1mrLMjU.d.cts → fk-diff-BWdQ7kRZ.d.cts} +2 -1
  9. package/dist/index.cjs +3 -3
  10. package/dist/index.d.cts +15 -9
  11. package/dist/index.d.mts +15 -9
  12. package/dist/index.mjs +3 -3
  13. package/dist/plugin.cjs +21 -4
  14. package/dist/plugin.mjs +21 -4
  15. package/dist/rel.d.cts +2 -2
  16. package/dist/rel.d.mts +2 -2
  17. package/dist/{relation-helpers-BUsxNNGU.d.mts → relation-helpers-17_2f5hX.d.mts} +1 -1
  18. package/dist/{relation-helpers-D_8bydc2.d.cts → relation-helpers-BLBrLQYi.d.cts} +1 -1
  19. package/dist/{search-fields-DBIZKwyb.mjs → search-fields-CE8cmj9X.mjs} +1 -0
  20. package/dist/{search-fields-DwrsJUC1.cjs → search-fields-CT_UZ9fg.cjs} +1 -0
  21. package/dist/shared.cjs +2 -1
  22. package/dist/shared.d.cts +10 -3
  23. package/dist/shared.d.mts +10 -3
  24. package/dist/shared.mjs +2 -2
  25. package/dist/sync.cjs +317 -35
  26. package/dist/sync.d.cts +34 -5
  27. package/dist/sync.d.mts +34 -5
  28. package/dist/sync.mjs +317 -35
  29. package/dist/{validation-utils-DqZAH6aT.mjs → validation-utils-BGjqc2jU.mjs} +24 -6
  30. package/dist/{validation-utils-debIYpq0.cjs → validation-utils-CtLNlBtB.cjs} +29 -5
  31. package/dist/{validator-BACewoRj.mjs → validator-B3fPJjS1.mjs} +178 -10
  32. package/dist/{validator-BxWwh3wf.cjs → validator-B4YDLUkj.cjs} +201 -9
  33. package/dist/validator.cjs +1 -1
  34. package/dist/validator.mjs +1 -1
  35. package/package.json +6 -6
@@ -1,12 +1,12 @@
1
1
  import { a as aggregateExpressionsNotSupported, n as CasMismatchError, r as DbError, u as spaceClosedError } from "./db-error-eSsQX7rt.mjs";
2
2
  import { a as SOURCE_VALUE_FNS, c as isFirstLast, o as SUPPORTED_AGGREGATE_FNS, r as BASE_AGGREGATE_FNS, t as AGG_ANNOTATIONS } from "./aggregate-fns-B_sgl4mB.mjs";
3
3
  import { A as hasRelationOp, D as containsRelationFilter, F as resolveRelationFilterTree, H as tableNameOf, I as compositeKey, J as viewExprNullable, K as isFieldRef, L as findFKEntryForRelation, M as noteRelationFilter, N as relGuardState, O as createRelationFilterHost, R as findFKForRelation, S as uniqueKeyTuple, U as computeOf, V as slicePerGroup, W as computedOperands, Y as walkViewExpr, _ as remapDeleteFkViolation, a as batchPatchNestedFrom, b as rowMatchesKey, c as batchReplaceNestedFrom, d as checkDepthOverflow, f as planNestedFromVia, g as enrichFkViolation, h as validateBatch, i as batchInsertNestedVia, l as batchReplaceNestedTo, m as preValidateNestedFrom, n as batchInsertNestedFrom, p as planPatchNestedTo, r as batchInsertNestedTo, s as batchPatchNestedVia, t as applyPatchNestedTo, u as batchReplaceNestedVia, v as findRowsByKeys, x as sameKey, y as pkTupleKey, z as findRemoteFK } from "./nested-writer-BOj_c_lI.mjs";
4
- import { a as DERIVED_INCOMPATIBLE, n as searchMemberKind, r as numericTypeProblem, s as isJsonLeafType } from "./search-fields-DBIZKwyb.mjs";
4
+ import { a as forceNavNonOptional, c as getKeyProps, d as soleUnionMember, f as unionValueMembers, i as dbPlugin, l as columnUnionBase, n as buildPatchPartial, t as buildDbValidator, u as pathPresence } from "./validator-B3fPJjS1.mjs";
5
+ import { a as DERIVED_INCOMPATIBLE, n as searchMemberKind, r as numericTypeProblem, s as isJsonLeafType } from "./search-fields-CE8cmj9X.mjs";
5
6
  import { a as isPlainObject, i as isEmptyObject, n as findAncestorInSet, o as selfOrAncestor, r as getPath, t as deletePath } from "./object-TkiJQ-Dp.mjs";
6
7
  import { resolveAlias as resolveAlias$1 } from "./agg.mjs";
7
8
  import { t as NOT_DIMENSION_REASON } from "./aggregate-rules-jdPrxqWa.mjs";
8
9
  import { isDbFieldOp, separateCas, separateFieldOps } from "./ops.mjs";
9
- import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, n as buildPatchPartial, t as buildDbValidator } from "./validator-BACewoRj.mjs";
10
10
  import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
11
11
  import { ARITH_MAX_NODES, BUCKET_UNITS, isAggregateExpr, isAggregateOfExpr, isBucketExpr, isNullsPlacement, isPrimitive, isRelationOp, isSelectArithExpr, resolveAlias, resolveBuckets } from "@uniqu/core";
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -215,7 +215,7 @@ function sourceIndex(type) {
215
215
  if (metadata.has("db.ignore") || navFields.has(path)) idx.unstored.add(path);
216
216
  if (metadata.has("db.column.derived") && fieldType.ref?.field) idx.derived.set(path, fieldType.ref.field);
217
217
  if (metadata.has("db.encrypted")) storage.push([path, false]);
218
- else if (metadata.has("db.json") || resolveDesignType(fieldType) === "array") storage.push([path, true]);
218
+ else if (metadata.has("db.json") || resolveDesignType(fieldType) === "array" || isStructuredMixedUnion(fieldType)) storage.push([path, true]);
219
219
  }
220
220
  storage.sort(([a], [b]) => a.split(".").length - b.split(".").length);
221
221
  for (const [path, json] of storage) if (findAncestorInSet(path, idx.jsonRoots) === void 0 && findAncestorInSet(path, idx.encrypted) === void 0) (json ? idx.jsonRoots : idx.encrypted).add(path);
@@ -264,8 +264,8 @@ function sourceFieldMetadata(sourceType, logicalPath) {
264
264
  * aggregate's field, a predicate operand) to where it is physically stored.
265
265
  * Internal — `AtscriptDbView.resolveRefSource` is the public entry.
266
266
  *
267
- * Relational rules (`TableMetadata`'s): the outermost `@db.json` or array
268
- * node with segments remaining is the column and the rest becomes
267
+ * Relational rules (`TableMetadata`'s): the outermost `@db.json`, array or
268
+ * structured mixed union (`isStructuredMixedUnion`) node with segments remaining is the column and the rest becomes
269
269
  * {@link TViewSource.jsonPath}; a flattened leaf is its parent segments
270
270
  * joined with `__` plus its `@db.column` (or segment); a top-level field is
271
271
  * its `@db.column` or name. Nested-object adapters use the document path
@@ -297,7 +297,8 @@ function resolveViewSource(sourceType, logicalPath, nestedObjects) {
297
297
  };
298
298
  }
299
299
  const jsonRoot = selfOrAncestor(logicalPath, idx.jsonRoots);
300
- const optional = selfOrAncestor(logicalPath, idx.optional) !== void 0 || jsonRoot !== void 0 && jsonRoot !== logicalPath;
300
+ const presence = pathPresence(sourceType, logicalPath);
301
+ const optional = (presence === void 0 ? selfOrAncestor(logicalPath, idx.optional) !== void 0 : presence !== "required") || jsonRoot !== void 0 && jsonRoot !== logicalPath;
301
302
  if (!node) return {
302
303
  column: logicalPath,
303
304
  designType: "unknown",
@@ -385,6 +386,38 @@ function relationalColumnName(path, override, flattened) {
385
386
  function isNavRelation(metadata) {
386
387
  return metadata.has("db.rel.to") || metadata.has("db.rel.from") || metadata.has("db.rel.via");
387
388
  }
389
+ /**
390
+ * A union whose value members disagree on their design type and include a
391
+ * structure — `Addr | string`, or the leaf `a` of `{ a: Addr } | { a: string }`.
392
+ * It has no column layout of its own: relational storage keeps it as one
393
+ * JSON column, like `@db.json` (since 0.1.155).
394
+ */
395
+ function isStructuredMixedUnion(type) {
396
+ if (type.type.kind !== "union" || resolveDesignType(type) !== "union") return false;
397
+ return unionValueMembers(type).members.some((member) => {
398
+ const kind = member.type.kind;
399
+ return kind === "object" || kind === "array" || kind === "tuple" || kind === "intersection";
400
+ });
401
+ }
402
+ /**
403
+ * The type a column of `type` is described by: the value member of a
404
+ * nullable union (`number.int | null` → `number.int`, so type mappers see
405
+ * its tags) carrying the field's own annotations and optionality, else
406
+ * `type` itself.
407
+ */
408
+ function columnType(type) {
409
+ const member = soleUnionMember(type);
410
+ if (!member) return type;
411
+ return {
412
+ __is_atscript_annotated_type: true,
413
+ type: member.type,
414
+ metadata: type.metadata,
415
+ optional: type.optional,
416
+ id: member.id,
417
+ ref: member.ref,
418
+ validator: (...args) => type.validator(...args)
419
+ };
420
+ }
388
421
  /** Returns true if the annotated type IS the `db.geoPoint` primitive (tag-based). */
389
422
  function isGeoPointType(fieldType) {
390
423
  return fieldType.type.tags?.has("geoPoint") === true;
@@ -470,6 +503,12 @@ var TableMetadata = class {
470
503
  /** Adapter-contributed multi-field unique keys (logical paths) — see `TMetadataOverrides.addUniqueKeys`. */
471
504
  uniqueKeys = [];
472
505
  defaults = /* @__PURE__ */ new Map();
506
+ /**
507
+ * Logical paths annotated with `@db.onUpdate.now` (`number.timestamp.updated`
508
+ * applies it): set to the current time by every update and replace.
509
+ * @since 0.1.156
510
+ */
511
+ onUpdateNow = /* @__PURE__ */ new Set();
473
512
  /** Logical path → `@db.column` override (top-level keys only on document storage). */
474
513
  columnMap = /* @__PURE__ */ new Map();
475
514
  dimensions = [];
@@ -554,6 +593,9 @@ var TableMetadata = class {
554
593
  _alwaysAddressable;
555
594
  _collateMap = /* @__PURE__ */ new Map();
556
595
  _columnFromMap = /* @__PURE__ */ new Map();
596
+ /** The annotated type `build()` ran on — the root of {@link presence}. */
597
+ _rootType;
598
+ _presence = /* @__PURE__ */ new Map();
557
599
  constructor(nestedObjects) {
558
600
  this.nestedObjects = nestedObjects;
559
601
  }
@@ -561,6 +603,72 @@ var TableMetadata = class {
561
603
  return this._built;
562
604
  }
563
605
  /**
606
+ * Whether the value of the LOGICAL field `path` is always there, may be
607
+ * NULL / missing, or is declared by only some members of a union of
608
+ * objects on the way ({@link TPathPresence}); `undefined` inside an array.
609
+ * @since 0.1.155
610
+ */
611
+ presence(path) {
612
+ if (this._presence.has(path)) return this._presence.get(path);
613
+ const root = this._rootType;
614
+ const answer = root ? pathPresence(root, path) : void 0;
615
+ this._presence.set(path, answer);
616
+ return answer;
617
+ }
618
+ /**
619
+ * Whether the LOGICAL field `path` can hold NULL (or be missing): it or a
620
+ * parent object is optional or a `| null` union, or only some members of
621
+ * a union of objects declare it. Inside an array: the field or a parent
622
+ * is optional.
623
+ * @since 0.1.155
624
+ */
625
+ isNullable(path) {
626
+ const presence = this.presence(path);
627
+ if (presence !== void 0) return presence !== "required";
628
+ for (let p = path;;) {
629
+ if (this.flatMap.get(p)?.optional === true) return true;
630
+ const dot = p.lastIndexOf(".");
631
+ if (dot === -1) return false;
632
+ p = p.slice(0, dot);
633
+ }
634
+ }
635
+ _objectLeaves;
636
+ /**
637
+ * The stored fields under the object field `path` that a null test on the
638
+ * object reads (`rewriteObjectNullTests`, since 0.1.155): its leaf columns
639
+ * on relational storage, its scalar / array / JSON fields on document
640
+ * storage (not inside an array or a JSON value). `undefined` when `path`
641
+ * is no stored object (a leaf, a `@db.json` / encrypted / ignored field,
642
+ * a navigation relation, inside a JSON value, unknown).
643
+ */
644
+ objectLeaves(path) {
645
+ this._objectLeaves ??= /* @__PURE__ */ new Map();
646
+ if (this._objectLeaves.has(path)) return this._objectLeaves.get(path);
647
+ let leaves;
648
+ const fd = this.descriptorByPath.get(path);
649
+ if (findAncestorInSet(path, this.jsonValueParents) === void 0 && (this.nestedObjects ? fd !== void 0 && fd.designType === "object" && fd.storage !== "json" : this.flattenedParents.has(path) && !this.ignoredFields.has(path))) {
650
+ const prefix = `${path}.`;
651
+ leaves = [];
652
+ for (const [leaf, leafFd] of this.descriptorByPath) {
653
+ if (!leaf.startsWith(prefix) || leafFd.designType === "object") continue;
654
+ if (findAncestorInSet(leaf, this.jsonValueParents) !== void 0) continue;
655
+ if (this.presence(leaf) === void 0) continue;
656
+ leaves.push(leaf);
657
+ }
658
+ }
659
+ this._objectLeaves.set(path, leaves);
660
+ return leaves;
661
+ }
662
+ /**
663
+ * Whether the column of `path` is nullable: {@link isNullable}, except
664
+ * inside an array (document storage only), where the field's own
665
+ * optionality counts.
666
+ */
667
+ _columnNullable(path, type) {
668
+ const presence = this.presence(path);
669
+ return presence === void 0 ? type.optional === true : presence !== "required";
670
+ }
671
+ /**
564
672
  * {@link documentPath} over this table's `columnMap`. A `@db.column.derived`
565
673
  * field has no stored path of its own: it maps to its source leaf.
566
674
  */
@@ -604,6 +712,7 @@ var TableMetadata = class {
604
712
  build(type, adapter, logger) {
605
713
  if (this._built) return;
606
714
  adapter.onBeforeFlatten?.(type);
715
+ this._rootType = type;
607
716
  const collected = [];
608
717
  this.flatMap = flattenAnnotatedType(type, {
609
718
  topLevelArrayTag: adapter.getTopLevelArrayTag?.() ?? "db.__topLevelArray",
@@ -662,10 +771,13 @@ var TableMetadata = class {
662
771
  * into the synthetic flat entry of the prop's path, so a member's
663
772
  * annotations would reach the column (DDL type, size, default). Since
664
773
  * atscript 0.1.103 a built-in primitive member carries its built-in
665
- * annotations (`number.timestamp.created | null` → `@db.default.now`,
666
- * `string.char` → `@expect.maxLength 1`). Rebuild such an entry from the
667
- * prop's own annotations plus the non-`db.*` annotations of named members
668
- * (aliases, interfaces), so the column is described as before 0.1.103.
774
+ * annotations (`string.char` → `@expect.maxLength 1`). Rebuild such an
775
+ * entry from the prop's own annotations plus the non-`db.*` annotations of
776
+ * named members (aliases, interfaces), so the column is described as before
777
+ * 0.1.103. Exception: the one non-`null` member of `T | null` is the
778
+ * column's type, so its `db.*` annotations apply too — `createdAt:
779
+ * number.timestamp.created | null` gets `@db.default.now` like
780
+ * `createdAt: number.timestamp.created`.
669
781
  */
670
782
  _dropMemberMetadata(collected) {
671
783
  const byPath = /* @__PURE__ */ new Map();
@@ -678,12 +790,16 @@ var TableMetadata = class {
678
790
  if (!entries.some((e) => e.member)) continue;
679
791
  const flat = this.flatMap.get(path);
680
792
  if (!flat?.__flat_union) continue;
793
+ const prop = entries.find((e) => !e.member);
794
+ const base = prop && columnUnionBase(prop.type);
681
795
  const target = flat.metadata;
682
796
  target.clear();
683
797
  for (const entry of entries) {
684
- if (entry.member && isInlinePrimitive(entry.type)) continue;
685
- for (const [key, value] of entry.metadata) if (!entry.member || !key.startsWith("db.")) target.set(key, value);
798
+ const inline = entry.member && isInlinePrimitive(entry.type);
799
+ const isBase = entry.member && entry.type === base;
800
+ for (const [key, value] of entry.metadata) if (!entry.member || (key.startsWith("db.") ? isBase : !inline)) target.set(key, value);
686
801
  }
802
+ if (base && prop) prop.metadata = target;
687
803
  }
688
804
  }
689
805
  /**
@@ -706,7 +822,7 @@ var TableMetadata = class {
706
822
  * Scans `@db.*` and `@meta.id` annotations on a field during flattening.
707
823
  */
708
824
  _scanGenericAnnotations(fieldName, fieldType, metadata, logger) {
709
- if (metadata.has("meta.id")) {
825
+ if (metadata.has("meta.id") && !fieldName.includes(".")) {
710
826
  this.primaryKeys.push(fieldName);
711
827
  this.originalMetaIdFields.push(fieldName);
712
828
  }
@@ -717,6 +833,7 @@ var TableMetadata = class {
717
833
  if (columnFrom && renamable) this._columnFromMap.set(fieldName, columnFrom);
718
834
  const resolvedDefault = resolveDefaultFromMetadata(metadata);
719
835
  if (resolvedDefault) this.defaults.set(fieldName, resolvedDefault);
836
+ if (metadata.has("db.onUpdate.now")) this.onUpdateNow.add(fieldName);
720
837
  if (metadata.has("db.ignore")) this.ignoredFields.add(fieldName);
721
838
  if (isNavRelation(metadata)) {
722
839
  this.navFields.add(fieldName);
@@ -886,6 +1003,7 @@ var TableMetadata = class {
886
1003
  if (metadata.has("db.search.vector") || metadata.has("db.search.filter")) reject("@db.search.*", "search over ciphertext is impossible");
887
1004
  if (metadata.has("db.mongo.search.text") || metadata.has("db.mongo.search.autocomplete")) reject("@db.mongo.search.*", "Atlas Search over ciphertext is impossible");
888
1005
  if (metadata.has("db.column.version")) reject("@db.column.version", "the OCC filter needs cleartext equality");
1006
+ if (metadata.has("db.onUpdate.now")) reject("@db.onUpdate.now", "the update time is set after the encryption transform");
889
1007
  if (metadata.has("db.default.increment") || metadata.has("db.default.now")) reject("@db.default.increment / @db.default.now", "engine-side defaults bypass the encryption transform");
890
1008
  if (metadata.get("db.patch.strategy") === "merge") reject("@db.patch.strategy \"merge\"", "ciphertext is opaque — partial merges would silently drop omitted keys");
891
1009
  }
@@ -969,7 +1087,7 @@ var TableMetadata = class {
969
1087
  const isJson = this.jsonFields.has(path);
970
1088
  const isArray = designType === "array";
971
1089
  const isObject = designType === "object";
972
- if (isArray) this.jsonFields.add(path);
1090
+ if (isArray || isStructuredMixedUnion(type)) this.jsonFields.add(path);
973
1091
  else if (isObject && isJson) {} else if (isObject && !isJson) this.flattenedParents.add(path);
974
1092
  }
975
1093
  for (const ignoredField of this.ignoredFields) if (this.flattenedParents.has(ignoredField)) {
@@ -1071,6 +1189,7 @@ var TableMetadata = class {
1071
1189
  const isJson = this.jsonFields.has(path);
1072
1190
  const isFlattened = !skipFlattening && findAncestorInSet(path, this.flattenedParents) !== void 0;
1073
1191
  const designType = isEncrypted ? "string" : isJson ? "json" : resolveDesignType(type);
1192
+ const colType = columnType(type);
1074
1193
  let storage;
1075
1194
  if (skipFlattening) storage = "column";
1076
1195
  else if (isEncrypted) storage = isFlattened ? "flattened" : "column";
@@ -1089,10 +1208,10 @@ var TableMetadata = class {
1089
1208
  if (quantityRef) this.quantityRefByField.set(path, quantityRef);
1090
1209
  descriptors.push({
1091
1210
  path,
1092
- type,
1211
+ type: colType,
1093
1212
  physicalName,
1094
1213
  designType,
1095
- optional: type.optional === true,
1214
+ optional: skipFlattening ? type.optional === true : this._columnNullable(path, type),
1096
1215
  isPrimaryKey: this.primaryKeys.includes(path),
1097
1216
  ignored: this.ignoredFields.has(path),
1098
1217
  defaultValue: this.defaults.get(path),
@@ -1106,7 +1225,7 @@ var TableMetadata = class {
1106
1225
  unitCode,
1107
1226
  unitRefField,
1108
1227
  encrypted: isEncrypted || underEncrypted || void 0,
1109
- isGeoPoint: isGeoPointType(type) || void 0,
1228
+ isGeoPoint: isGeoPointType(colType) || void 0,
1110
1229
  derived: this.derivedFields.get(path),
1111
1230
  computed: computedMeta(rootType, path)
1112
1231
  });
@@ -1234,7 +1353,7 @@ var TableMetadata = class {
1234
1353
  for (const index of this.indexes.values()) for (const field of index.fields) {
1235
1354
  const ftype = this.flatMap.get(field.name);
1236
1355
  if (ftype) {
1237
- field.optional = ftype.optional === true;
1356
+ field.optional = this._columnNullable(field.name, ftype);
1238
1357
  field.designType = ftype.type.tags?.has("objectId") ? "objectId" : resolveDesignType(ftype);
1239
1358
  }
1240
1359
  field.name = this.physicalPath(field.name);
@@ -1647,6 +1766,78 @@ function rewriteIntegerRegex(filter, meta) {
1647
1766
  return out ?? filter;
1648
1767
  }
1649
1768
  //#endregion
1769
+ //#region src/query/object-null.ts
1770
+ /**
1771
+ * Whether a filter entry's value only asks if the value is there: `null`,
1772
+ * `{ $eq: null }`, `{ $ne: null }`, `{ $exists: true | false }` (and
1773
+ * combinations of them).
1774
+ */
1775
+ function isNullTest(value) {
1776
+ if (value === null) return true;
1777
+ if (!isPlainObject(value)) return false;
1778
+ const ops = Object.entries(value);
1779
+ return ops.length > 0 && ops.every(([op, operand]) => op === "$exists" ? typeof operand === "boolean" : (op === "$eq" || op === "$ne") && operand === null);
1780
+ }
1781
+ /**
1782
+ * The leaf form of one null test on an object (see
1783
+ * {@link rewriteObjectNullTests}) as `$and` members: absent — `$exists: false`
1784
+ * on every leaf; present — one `$or` of `$exists: true` per leaf.
1785
+ */
1786
+ function objectTest(value, leaves) {
1787
+ const ops = value === null ? { $eq: null } : value;
1788
+ const out = [];
1789
+ for (const [op, operand] of Object.entries(ops)) {
1790
+ const present = op === "$exists" ? operand : op === "$ne";
1791
+ const tests = leaves.map((leaf) => ({ [leaf]: { $exists: present } }));
1792
+ if (present) out.push({ $or: tests });
1793
+ else out.push(...tests);
1794
+ }
1795
+ return out;
1796
+ }
1797
+ /**
1798
+ * A null test on a stored object (since 0.1.155) — `{ address: null }`,
1799
+ * `{ address: { $ne: null } }`, `{ address: { $exists: … } }` — on the
1800
+ * object's leaves: the object is null when none of its fields holds a value
1801
+ * (`$exists: false` on every leaf), present when one does. Relational
1802
+ * storage has no column for the object (an all-NULL row reads back as
1803
+ * `null`); document storage answers the same, so an object stored as `{}`
1804
+ * or with only null fields counts as null everywhere. LOGICAL filter;
1805
+ * copy-on-write (the same object comes back when nothing changed).
1806
+ */
1807
+ function rewriteObjectNullTests(filter, meta) {
1808
+ if (!isPlainObject(filter)) return filter;
1809
+ let out;
1810
+ const extra = [];
1811
+ for (const [key, value] of Object.entries(filter)) {
1812
+ let next = value;
1813
+ if (key === "$and" || key === "$or") {
1814
+ if (Array.isArray(value)) {
1815
+ const mapped = value.map((f) => rewriteObjectNullTests(f, meta));
1816
+ if (mapped.some((m, i) => m !== value[i])) next = mapped;
1817
+ }
1818
+ } else if (key === "$not") next = rewriteObjectNullTests(value, meta);
1819
+ else if (!key.startsWith("$") && isNullTest(value)) {
1820
+ const leaves = meta.objectLeaves(key);
1821
+ if (leaves && leaves.length > 0) {
1822
+ extra.push(...objectTest(value, leaves));
1823
+ out ??= { ...filter };
1824
+ delete out[key];
1825
+ continue;
1826
+ }
1827
+ }
1828
+ if (next !== value) {
1829
+ out ??= { ...filter };
1830
+ out[key] = next;
1831
+ }
1832
+ }
1833
+ if (!out) return filter;
1834
+ if (extra.length > 0) {
1835
+ const and = out.$and;
1836
+ out.$and = [...Array.isArray(and) ? and : [], ...extra];
1837
+ }
1838
+ return out;
1839
+ }
1840
+ //#endregion
1650
1841
  //#region src/query/aggregate-expr.ts
1651
1842
  /**
1652
1843
  * Output aliases of the computed `$select` entries of a grouped query —
@@ -2298,7 +2489,7 @@ var FieldMappingStrategy = class {
2298
2489
  * translate predicate operands at deeper levels).
2299
2490
  */
2300
2491
  translateFilter(filter, meta, depth = 0) {
2301
- filter = rewriteIntegerRegex(filter, meta);
2492
+ filter = rewriteObjectNullTests(rewriteIntegerRegex(filter, meta), meta);
2302
2493
  const has = containsRelationFilter(filter);
2303
2494
  const resolved = has ? resolveRelationFilterTree(filter, meta, depth) : filter;
2304
2495
  return this.noteTranslated(filter, this.translateResolvedFilter(resolved, meta), has);
@@ -2640,13 +2831,13 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2640
2831
  current = created;
2641
2832
  } else current = next;
2642
2833
  }
2643
- current[col.last] = value;
2834
+ if (!col.partial || value !== null && value !== void 0) current[col.last] = value;
2644
2835
  }
2645
2836
  for (const parent of plan.parents) collapseNullParent(result, parent);
2646
2837
  return result;
2647
2838
  }
2648
2839
  translateQuery(query, meta) {
2649
- const logical = rewriteIntegerRegex(query.filter, meta);
2840
+ const logical = rewriteObjectNullTests(rewriteIntegerRegex(query.filter, meta), meta);
2650
2841
  const has = containsRelationFilter(logical);
2651
2842
  const filter = has ? resolveRelationFilterTree(logical, meta, 0) : logical;
2652
2843
  if (!meta.requiresMappings) {
@@ -2701,7 +2892,11 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2701
2892
  return result;
2702
2893
  }
2703
2894
  prepareForWrite(payload, meta, adapter) {
2704
- if (meta.requiresMappings && !meta.onlyColumnRenames) return this.formatWriteValues(this.flattenWritePayload(payload, meta, adapter), meta);
2895
+ if (meta.requiresMappings && !meta.onlyColumnRenames) {
2896
+ const flat = this.flattenWritePayload(payload, meta, adapter);
2897
+ nullAbsentNested(payload, flat, meta);
2898
+ return this.formatWriteValues(flat, meta);
2899
+ }
2705
2900
  const data = { ...payload };
2706
2901
  this.prepareCommon(data, meta, adapter);
2707
2902
  if (meta.onlyColumnRenames) {
@@ -2728,7 +2923,7 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2728
2923
  const suffix = operatorMatch ? operatorMatch[2] : "";
2729
2924
  const fd = meta.leafByLogical.get(basePath);
2730
2925
  const finalKey = (fd?.physicalName ?? basePath) + suffix;
2731
- if (fd?.storage === "json" && typeof value === "object" && value !== null && !suffix) result[finalKey] = JSON.stringify(value);
2926
+ if (fd?.storage === "json" && value !== null && value !== void 0 && !suffix) result[finalKey] = JSON.stringify(value);
2732
2927
  else result[finalKey] = value;
2733
2928
  }
2734
2929
  return this.formatWriteValues(result, meta);
@@ -2808,6 +3003,49 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2808
3003
  return result;
2809
3004
  }
2810
3005
  };
3006
+ const nestedColumns = /* @__PURE__ */ new WeakMap();
3007
+ function nestedColumnsFor(meta) {
3008
+ let list = nestedColumns.get(meta);
3009
+ if (list === void 0) {
3010
+ list = [];
3011
+ for (const [physical, fd] of meta.leafByPhysical) {
3012
+ if (fd.derived || fd.isPrimaryKey || fd.storage === "column" || !fd.path.includes(".")) continue;
3013
+ const segs = fd.path.split(".");
3014
+ list.push({
3015
+ physical,
3016
+ parents: segs.slice(0, -1),
3017
+ partial: meta.presence(fd.path) === "partial"
3018
+ });
3019
+ }
3020
+ nestedColumns.set(meta, list);
3021
+ }
3022
+ return list;
3023
+ }
3024
+ /**
3025
+ * Every write through `prepareForWrite` stores a whole row (insert, replace).
3026
+ * A column the flattened row leaves out would take its column `DEFAULT` —
3027
+ * a `now` default below an absent optional object would make the object
3028
+ * reappear on read, and one of another union member's leaves would add a
3029
+ * field to the stored member. So a column below an absent / `null` parent
3030
+ * object, or one only some union members declare that the written member
3031
+ * leaves out, is written as `NULL` (since 0.1.155), as the document adapters
3032
+ * store such a row.
3033
+ */
3034
+ function nullAbsentNested(payload, flat, meta) {
3035
+ for (const col of nestedColumnsFor(meta)) {
3036
+ if (col.physical in flat) continue;
3037
+ let absent = false;
3038
+ let current = payload;
3039
+ for (const seg of col.parents) {
3040
+ current = current[seg];
3041
+ if (current === null || current === void 0 || typeof current !== "object") {
3042
+ absent = true;
3043
+ break;
3044
+ }
3045
+ }
3046
+ if (absent || col.partial) flat[col.physical] = null;
3047
+ }
3048
+ }
2811
3049
  /**
2812
3050
  * Per-table read plan of {@link RelationalFieldMapper.reconstructFromRead}:
2813
3051
  * the per-column decisions (coercion, JSON, nesting — the dot-path pre-split)
@@ -2827,12 +3065,14 @@ function compileReadPlan(meta) {
2827
3065
  const fromFmts = meta.fromStorageFormatters;
2828
3066
  const columns = /* @__PURE__ */ new Map();
2829
3067
  for (const [physical, fd] of meta.leafByPhysical) {
2830
- const segs = (fd.storage === "json" || fd.storage === "flattened") && fd.path.includes(".") ? fd.path.split(".") : void 0;
3068
+ const nested = (fd.storage === "json" || fd.storage === "flattened") && fd.path.includes(".");
3069
+ const segs = nested ? fd.path.split(".") : void 0;
2831
3070
  columns.set(physical, {
2832
3071
  path: fd.path,
2833
3072
  parents: segs?.slice(0, -1),
2834
3073
  last: segs ? segs[segs.length - 1] : fd.path,
2835
3074
  json: fd.storage === "json",
3075
+ partial: nested && meta.presence(fd.path) === "partial",
2836
3076
  coerce: fd.designType === "boolean" ? toBool : fd.designType === "decimal" ? toDecimalString : void 0,
2837
3077
  fromFmt: fromFmts?.get(physical)
2838
3078
  });
@@ -2843,15 +3083,16 @@ function compileReadPlan(meta) {
2843
3083
  parents.push({
2844
3084
  ancestors: segs.slice(0, -1),
2845
3085
  last: segs[segs.length - 1],
2846
- optional: !!meta.flatMap?.get(parentPath)?.optional
3086
+ presence: meta.presence(parentPath) ?? "required"
2847
3087
  });
2848
3088
  }
3089
+ parents.sort((a, b) => b.ancestors.length - a.ancestors.length);
2849
3090
  return {
2850
3091
  columns,
2851
3092
  parents
2852
3093
  };
2853
3094
  }
2854
- /** If every child of a flattened parent is null / undefined, collapse it (`null` when optional, else `{}`). */
3095
+ /** If every child of a flattened parent is null / undefined, collapse it (see {@link TReadParent}). */
2855
3096
  function collapseNullParent(obj, parent) {
2856
3097
  let current = obj;
2857
3098
  const ancestors = parent.ancestors;
@@ -2866,7 +3107,8 @@ function collapseNullParent(obj, parent) {
2866
3107
  const v = parentObj[k];
2867
3108
  if (v !== null && v !== void 0) return;
2868
3109
  }
2869
- current[parent.last] = parent.optional ? null : {};
3110
+ if (parent.presence === "partial") delete current[parent.last];
3111
+ else current[parent.last] = parent.presence === "nullable" ? null : {};
2870
3112
  }
2871
3113
  /**
2872
3114
  * Per-table write plan of the relational flatten: each logical path's
@@ -3308,6 +3550,10 @@ function collectQueryPaths(query, aggregate) {
3308
3550
  path,
3309
3551
  predicate,
3310
3552
  relation: relationOpsOf(value)
3553
+ } : isNullTest(value) ? {
3554
+ path,
3555
+ predicate,
3556
+ nullTest: true
3311
3557
  } : {
3312
3558
  path,
3313
3559
  predicate
@@ -3407,10 +3653,11 @@ function pathSourceOf(meta) {
3407
3653
  * `FieldCapabilityIndex`, with `$with` hints and leaf lists) is what clients
3408
3654
  * see and is authoritative — the HTTP gate always answers first.
3409
3655
  */
3410
- function guardPath(meta, adapter, path, op, predicate = "compare") {
3656
+ function guardPath(meta, adapter, path, op, predicate = "compare", nullTest = false) {
3411
3657
  const verb = OP_VERB[op];
3412
3658
  const { kind, parent } = classifyQueryPath(pathSourceOf(meta), path);
3413
3659
  if (predicate === "relation" && kind !== "nav" && kind !== "unknown") throw pathError(path, `"$some" / "$none" are only valid on a navigation relation — "${path}" is not one`);
3660
+ if (op === "filter" && nullTest && meta.objectLeaves(path)?.length) return;
3414
3661
  switch (kind) {
3415
3662
  case "nav":
3416
3663
  if (op === "filter" && predicate === "relation" && parent === void 0) return;
@@ -3495,7 +3742,7 @@ function guardPaths(meta, adapter, query, aggregate = false, state) {
3495
3742
  if (refs.unsupportedOperator !== void 0) throw pathError(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
3496
3743
  let relState = state;
3497
3744
  for (const ref of refs.filter) {
3498
- guardPath(meta, adapter, ref.path, "filter", ref.predicate);
3745
+ guardPath(meta, adapter, ref.path, "filter", ref.predicate, ref.nullTest);
3499
3746
  if (ref.predicate === "relation") guardRelationRef(meta, adapter, ref, relState ??= relGuardState());
3500
3747
  }
3501
3748
  for (const path of refs.sort) guardPath(meta, adapter, path, "sort");
@@ -3637,10 +3884,12 @@ var NullsSource = class {
3637
3884
  }
3638
3885
  /**
3639
3886
  * Whether ordering by the LOGICAL field `path` can meet NULL (or a missing
3640
- * value): the field or one of its parent objects is optional, the field is
3641
- * a derived column, it is not a known field, or the source is a view. A
3642
- * `$nulls` entry on any other field changes nothing and is dropped, so the
3643
- * plain `ORDER BY` keeps using the index.
3887
+ * value): the field can hold NULL (`TableMetadata.isNullable` — it or a
3888
+ * parent object is optional or a `| null` union, or only some members of a
3889
+ * union of objects declare it), the field is a derived column, it is not a
3890
+ * known field, or the source is a view. A `$nulls` entry on any other
3891
+ * field changes nothing and is dropped, so the plain `ORDER BY` keeps
3892
+ * using the index.
3644
3893
  */
3645
3894
  nullable(path) {
3646
3895
  let answer = this._nullable.get(path);
@@ -3654,12 +3903,7 @@ var NullsSource = class {
3654
3903
  const { meta } = this._init;
3655
3904
  const fd = meta.descriptorByPath.get(path);
3656
3905
  if (!fd || fd.derived) return true;
3657
- for (let p = path;;) {
3658
- if (meta.flatMap.get(p)?.optional === true) return true;
3659
- const dot = p.lastIndexOf(".");
3660
- if (dot === -1) return false;
3661
- p = p.slice(0, dot);
3662
- }
3906
+ return meta.isNullable(path);
3663
3907
  }
3664
3908
  /**
3665
3909
  * The effective `$nulls` of the `$sort` keys of one read: the request's
@@ -3774,7 +4018,9 @@ function geoIndexNotFoundMessage(tableName, indexName) {
3774
4018
  * otherwise trips up every adapter author.
3775
4019
  *
3776
4020
  * For union types (e.g., from flattened `{...} | {...}` objects):
3777
- * - If all members resolve to the same type → returns that type (strong type)
4021
+ * - `null` / `undefined` members are left out (since 0.1.155): `T | null`
4022
+ * resolves to the design type of `T`
4023
+ * - If all other members resolve to the same type → returns that type (strong type)
3778
4024
  * - If members disagree → returns `'union'` (out of scope for type management)
3779
4025
  */
3780
4026
  function resolveDesignType(fieldType) {
@@ -3782,9 +4028,9 @@ function resolveDesignType(fieldType) {
3782
4028
  if (fieldType.type.kind === "object") return "object";
3783
4029
  if (fieldType.type.kind === "array") return "array";
3784
4030
  if (fieldType.type.kind === "union") {
3785
- const items = fieldType.type.items;
3786
- if (items.length > 0) {
3787
- const resolved = items.map((item) => resolveDesignType(item));
4031
+ const { members } = unionValueMembers(fieldType);
4032
+ if (members.length > 0) {
4033
+ const resolved = members.map((item) => resolveDesignType(item));
3788
4034
  if (resolved.every((type) => type === resolved[0])) return resolved[0];
3789
4035
  }
3790
4036
  return "union";
@@ -4440,21 +4686,13 @@ var AtscriptDbReadable = class {
4440
4686
  _totalOrderKeys() {
4441
4687
  if (this._totalOrderKeysCache) return this._totalOrderKeysCache;
4442
4688
  const meta = this._meta;
4443
- const nullable = (logical) => {
4444
- for (let path = logical;;) {
4445
- if (meta.flatMap.get(path)?.optional === true) return true;
4446
- const dot = path.lastIndexOf(".");
4447
- if (dot === -1) return false;
4448
- path = path.slice(0, dot);
4449
- }
4450
- };
4451
4689
  const sets = [];
4452
4690
  if (meta.primaryKeys.length > 0) sets.push(meta.primaryKeys.map((f) => meta.physicalPath(f)));
4453
4691
  for (const index of meta.indexes.values()) {
4454
4692
  if (index.type !== "unique") continue;
4455
- if (index.fields.every((f) => f.optional !== true && !nullable(meta.physicalToPath.get(f.name) ?? f.name))) sets.push(index.fields.map((f) => f.name));
4693
+ if (index.fields.every((f) => f.optional !== true && !meta.isNullable(meta.physicalToPath.get(f.name) ?? f.name))) sets.push(index.fields.map((f) => f.name));
4456
4694
  }
4457
- for (const prop of meta.uniqueProps) if (!nullable(prop)) sets.push([meta.physicalPath(prop)]);
4695
+ for (const prop of meta.uniqueProps) if (!meta.isNullable(prop)) sets.push([meta.physicalPath(prop)]);
4458
4696
  this._totalOrderKeysCache = sets;
4459
4697
  return sets;
4460
4698
  }
@@ -6075,7 +6313,7 @@ var BaseDbAdapter = class {
6075
6313
  }
6076
6314
  /**
6077
6315
  * Whether the table has at least one row. Schema sync uses it in the
6078
- * pre-flight phase to refuse a primary-key change on a populated table.
6316
+ * pre-flight phase to check a primary-key change on a populated table.
6079
6317
  * Override with an EXISTS/LIMIT 1 probe — this default is `count() > 0`,
6080
6318
  * a full scan on some engines, and it can only answer for the adapter's
6081
6319
  * OWN table: for another `tableName` (or on an administrative adapter
@@ -6495,6 +6733,10 @@ function flattenPatchPayload(payload, prefix, update, meta, table) {
6495
6733
  const key = prefix ? `${prefix}.${_key}` : _key;
6496
6734
  if (primaryKeys.includes(key)) continue;
6497
6735
  if (!(typeof value === "object" && value !== null && !Array.isArray(value))) {
6736
+ if (value === null && meta.flattenedParents.has(key)) {
6737
+ for (const lp of meta.optionalLeavesByLogicalParent.get(key) ?? []) update[lp] = null;
6738
+ continue;
6739
+ }
6498
6740
  update[key] = value;
6499
6741
  continue;
6500
6742
  }
@@ -6575,10 +6817,11 @@ function assertNoVersionWrites(data, versionColumn) {
6575
6817
  * Whether a logical (pre-decomposition) patch writes ONLY version-exempt
6576
6818
  * fields (`@db.column.version.exempt`). `data` has nav fields, filter keys and
6577
6819
  * derived fields removed and `$cas` separated. Mirrors `flattenPatchPayload`
6578
- * (keep the two side by side): PK keys are skipped; an exempt path (or one
6579
- * under an exempt ancestor) is covered whatever its value (scalar, field op,
6580
- * array ops, object); a non-exempt merge-strategy object recurses (only the
6581
- * supplied children are written); anything else is a non-exempt write.
6820
+ * (keep the two side by side): PK keys and `@db.onUpdate.now` fields are
6821
+ * skipped; an exempt path (or one under an exempt ancestor) is covered
6822
+ * whatever its value (scalar, field op, array ops, object); a non-exempt
6823
+ * merge-strategy object recurses (only the supplied children are written);
6824
+ * anything else is a non-exempt write.
6582
6825
  * True iff at least one key is covered and none is not.
6583
6826
  * @since 0.1.150
6584
6827
  */
@@ -6587,11 +6830,12 @@ function isVersionExemptPatch(data, table) {
6587
6830
  if (meta.versionExemptPaths.size === 0) return false;
6588
6831
  const primaryKeys = meta.primaryKeys;
6589
6832
  const flatMap = meta.flatMap;
6833
+ const stamps = meta.onUpdateNow;
6590
6834
  const walk = (obj, prefix) => {
6591
6835
  let result = "none";
6592
6836
  for (const [k, value] of Object.entries(obj)) {
6593
6837
  const key = prefix ? `${prefix}.${k}` : k;
6594
- if (primaryKeys.includes(key)) continue;
6838
+ if (primaryKeys.includes(key) || stamps.has(key)) continue;
6595
6839
  if (meta.isVersionExemptPath(key)) {
6596
6840
  result = "exempt";
6597
6841
  continue;
@@ -6904,7 +7148,8 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6904
7148
  const singleStatement = !ignore && this._isSingleStatementWrite(payloads, depth, guard, check);
6905
7149
  return enrichFkViolation(this._meta, () => this._inWriteTransaction(singleStatement, async () => {
6906
7150
  const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
6907
- let items = payloads.map((p) => this._applyDefaults(clone(p)));
7151
+ const now = Date.now();
7152
+ let items = payloads.map((p) => this._applyDefaults(clone(p), now));
6908
7153
  if (ignore) this._linkToByKeyInIgnoreMode(items);
6909
7154
  let originals = canNest ? items.map((item) => ({ ...item })) : [];
6910
7155
  const validator = this.getValidator("insert");
@@ -6980,10 +7225,12 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6980
7225
  const versionColumn = this.versionColumn;
6981
7226
  const expectedVersions = Array.from({ length: payloads.length });
6982
7227
  const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
7228
+ const now = Date.now();
6983
7229
  const items = payloads.map((p, i) => {
6984
7230
  const c = clone(p);
6985
7231
  expectedVersions[i] = separateCas(c, versionColumn);
6986
- return this._applyDefaults(c);
7232
+ this._stampOnUpdate(c, now, true);
7233
+ return this._applyDefaults(c, now);
6987
7234
  });
6988
7235
  const originals = canNest ? items.map((item) => ({ ...item })) : [];
6989
7236
  const validator = this.getValidator("bulkReplace");
@@ -7067,9 +7314,11 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7067
7314
  const versionColumn = this.versionColumn;
7068
7315
  const expectedVersions = Array.from({ length: payloads.length });
7069
7316
  const clone = depth === 0 ? _cloneWritePayload : _shallowPrunedClone;
7317
+ const now = Date.now();
7070
7318
  const cloned = payloads.map((p, i) => {
7071
7319
  const c = clone(p);
7072
7320
  expectedVersions[i] = separateCas(c, versionColumn);
7321
+ this._stampOnUpdate(c, now, false);
7073
7322
  return c;
7074
7323
  });
7075
7324
  const validator = this.getValidator("bulkUpdate");
@@ -7116,6 +7365,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7116
7365
  continue;
7117
7366
  }
7118
7367
  const updateOpts = expectedVersion === void 0 && versionColumn !== void 0 && isVersionExemptPatch(data, this) ? [KEEP_VERSION] : [];
7368
+ this._stampTopLevel(data, now);
7119
7369
  let result;
7120
7370
  if (this.adapter.supportsNativePatch()) {
7121
7371
  const ops = separateFieldOps(data);
@@ -7275,6 +7525,8 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7275
7525
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver, true);
7276
7526
  const dataCopy = _cloneWritePayload(data);
7277
7527
  this._meta.stripDerived(dataCopy);
7528
+ const now = Date.now();
7529
+ this._stampOnUpdate(dataCopy, now, false);
7278
7530
  const versionColumn = this.versionColumn;
7279
7531
  if ("$cas" in dataCopy) throw new DbError("INVALID_QUERY", [{
7280
7532
  path: "$cas",
@@ -7282,6 +7534,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7282
7534
  }]);
7283
7535
  if (versionColumn !== void 0) assertNoVersionWrites(dataCopy, versionColumn);
7284
7536
  const updateOpts = versionColumn !== void 0 && isVersionExemptPatch(dataCopy, this) ? [KEEP_VERSION] : [];
7537
+ this._stampTopLevel(dataCopy, now);
7285
7538
  await this._encryptItems([dataCopy], "patch");
7286
7539
  const update = decomposePatch(dataCopy, this);
7287
7540
  const ops = separateFieldOps(update);
@@ -7302,6 +7555,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7302
7555
  this._guardMutationFilter(filter);
7303
7556
  await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver);
7304
7557
  const dataCopy = _cloneWritePayload(data);
7558
+ this._stampOnUpdate(dataCopy, Date.now(), true);
7305
7559
  await this._encryptItems([dataCopy], "write");
7306
7560
  return enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite(dataCopy, this._meta, this.adapter)));
7307
7561
  }
@@ -7672,27 +7926,79 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7672
7926
  * full row). Function defaults (`now` / `uuid` / `increment` / custom) the
7673
7927
  * adapter handles natively are NOT filled — the field stays absent so the
7674
7928
  * engine's own default applies. The version column is never touched.
7675
- */
7676
- _applyDefaults(data) {
7929
+ * A nested field (`audit.createdAt`) is filled inside its object, only when
7930
+ * that object is present (an absent or `null` embedded object stays so), and
7931
+ * in every object of an array on the way; a field inside a JSON value or an
7932
+ * array has no column default, so the SDK fills its function default too.
7933
+ * Every `now` default the SDK fills gets the one time `now` (one per write
7934
+ * call, so `createdAt` / `updatedAt` of a new row are equal).
7935
+ */
7936
+ _applyDefaults(data, now = Date.now()) {
7677
7937
  const nativeFns = this.adapter.nativeDefaultFns();
7678
7938
  const versionField = this._meta.versionField;
7679
7939
  for (const [field, def] of this._meta.defaults.entries()) {
7680
7940
  if (field === versionField) continue;
7681
- if (data[field] === void 0) {
7682
- if (def.kind === "value") data[field] = this._valueDefault(field, def.value);
7683
- else if (def.kind === "fn" && !nativeFns.has(def.fn)) switch (def.fn) {
7684
- case "now":
7685
- data[field] = Date.now();
7686
- break;
7687
- case "uuid":
7688
- data[field] = crypto.randomUUID();
7689
- break;
7690
- }
7941
+ let make;
7942
+ if (def.kind === "value") make = () => this._valueDefault(field, def.value);
7943
+ else if (def.kind === "fn" && (!nativeFns.has(def.fn) || findAncestorInSet(field, this._meta.jsonParents) !== void 0)) {
7944
+ if (def.fn === "now") make = () => now;
7945
+ else if (def.fn === "uuid") make = () => crypto.randomUUID();
7691
7946
  }
7947
+ if (!make) continue;
7948
+ if (!field.includes(".")) {
7949
+ if (data[field] === void 0) data[field] = make();
7950
+ } else if (this._nestedDefaultFillable(field)) fillNested(data, field.split("."), 0, make);
7692
7951
  }
7693
7952
  return data;
7694
7953
  }
7695
7954
  /**
7955
+ * `@db.onUpdate.now` (since 0.1.156): sets each such field of a replace /
7956
+ * patch payload to `now`, overriding the caller's value. A nested field
7957
+ * (`audit.updatedAt`) is set inside its object only when the payload
7958
+ * carries that object — in every item of an array on the way and of the
7959
+ * items an array patch operator adds or updates — like
7960
+ * {@link _applyDefaults}. `topLevel: false` (a patch) instead drops the
7961
+ * caller's values of the table's own columns: {@link _stampTopLevel} sets
7962
+ * them once the patch is known to write something.
7963
+ */
7964
+ _stampOnUpdate(data, now, topLevel) {
7965
+ for (const field of this._meta.onUpdateNow) if (!field.includes(".")) if (topLevel) data[field] = now;
7966
+ else delete data[field];
7967
+ else if (this._nestedDefaultFillable(field)) stampNested(data, field.split("."), 0, now);
7968
+ }
7969
+ /** Sets the top-level `@db.onUpdate.now` columns of a non-empty patch to `now`. */
7970
+ _stampTopLevel(data, now) {
7971
+ if (isEmptyObject(data)) return;
7972
+ for (const field of this._meta.onUpdateNow) if (!field.includes(".")) data[field] = now;
7973
+ }
7974
+ /** {@link _nestedDefaultFillable} per nested default path. */
7975
+ _fillableDefaults;
7976
+ /**
7977
+ * Whether every parent of the nested default `field` is one object type —
7978
+ * an object, `T | null` of one, or an array of one. Below a tuple or a
7979
+ * union of several types the default cannot be placed (the item it belongs
7980
+ * to is unknown), so it is not filled.
7981
+ */
7982
+ _nestedDefaultFillable(field) {
7983
+ const cache = this._fillableDefaults ??= /* @__PURE__ */ new Map();
7984
+ let fillable = cache.get(field);
7985
+ if (fillable === void 0) {
7986
+ fillable = true;
7987
+ for (let dot = field.indexOf("."); dot !== -1; dot = field.indexOf(".", dot + 1)) {
7988
+ let type = this.flatMap.get(field.slice(0, dot));
7989
+ if (type?.type.kind === "union") type = soleUnionMember(type);
7990
+ if (type?.type.kind === "array") type = type.type.of;
7991
+ if (type?.type.kind === "union") type = soleUnionMember(type);
7992
+ if (type?.type.kind !== "object") {
7993
+ fillable = false;
7994
+ break;
7995
+ }
7996
+ }
7997
+ cache.set(field, fillable);
7998
+ }
7999
+ return fillable;
8000
+ }
8001
+ /**
7696
8002
  * Parsed `@db.default 'literal'` values that are primitives — reused for
7697
8003
  * every row (an object / array default is parsed per row: each row gets
7698
8004
  * its own instance).
@@ -7857,6 +8163,42 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
7857
8163
  return this.createValidator({ plugins: adapterPlugins });
7858
8164
  }
7859
8165
  };
8166
+ /**
8167
+ * Sets `segs` below `value` to `now` (see `_stampOnUpdate`), through
8168
+ * arrays and the item lists of array patch operators (`$insert`, `$upsert`,
8169
+ * `$replace`, `$update` — not `$remove`).
8170
+ */
8171
+ function stampNested(value, segs, i, now) {
8172
+ if (Array.isArray(value)) {
8173
+ for (const item of value) stampNested(item, segs, i, now);
8174
+ return;
8175
+ }
8176
+ if (!isPlainObject(value)) return;
8177
+ const key = segs[i];
8178
+ if (i === segs.length - 1) {
8179
+ value[key] = now;
8180
+ return;
8181
+ }
8182
+ const next = value[key];
8183
+ if (isPlainObject(next) && Object.keys(next).some((k) => k.startsWith("$"))) {
8184
+ for (const [op, items] of Object.entries(next)) if (op !== "$remove") stampNested(items, segs, i + 1, now);
8185
+ } else stampNested(next, segs, i + 1, now);
8186
+ }
8187
+ /**
8188
+ * Sets `segs[i..]` to `make()` where it is `undefined`, inside present plain
8189
+ * objects only — fanned out over the objects of every array on the way.
8190
+ */
8191
+ function fillNested(value, segs, i, make) {
8192
+ if (Array.isArray(value)) {
8193
+ for (const item of value) fillNested(item, segs, i, make);
8194
+ return;
8195
+ }
8196
+ if (!isPlainObject(value)) return;
8197
+ const key = segs[i];
8198
+ if (i === segs.length - 1) {
8199
+ if (value[key] === void 0) value[key] = make();
8200
+ } else fillNested(value[key], segs, i + 1, make);
8201
+ }
7860
8202
  //#endregion
7861
8203
  //#region src/table/view-read-plan.ts
7862
8204
  /** Design types whose `=` compares one scalar to one scalar on every engine. */
@@ -8969,7 +9311,9 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
8969
9311
  });
8970
9312
  }
8971
9313
  const desiredDefault = serializeDefaultValue(field.defaultValue);
8972
- if (existingCol.dflt_value !== void 0 && existingCol.dflt_value !== desiredDefault) defaultChanged.push({
9314
+ const fn = field.defaultValue?.kind === "fn" ? field.defaultValue.fn : void 0;
9315
+ const missingNative = fn !== void 0 && fn !== "increment" && opts?.nativeDefaultFns?.has(fn) === true;
9316
+ if ((existingCol.dflt_value !== void 0 || missingNative) && existingCol.dflt_value !== desiredDefault) defaultChanged.push({
8973
9317
  field,
8974
9318
  oldDefault: existingCol.dflt_value,
8975
9319
  newDefault: desiredDefault
@@ -9006,4 +9350,4 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
9006
9350
  return diff;
9007
9351
  }
9008
9352
  //#endregion
9009
- export { DocumentFieldMapper as $, resolveDesignType as A, checkHavingKeys as B, ALL_BUCKET_UNITS as C, IntegrityStrategy as D, createFailureCollector as E, ENCRYPTED_REASON as F, guardFilter as G, collectQueryPaths as H, acceptedOperatorsHint as I, guardQuery as J, guardPath as K, assertGeoPoint as L, searchIndexNotFoundMessage as M, vectorIndexNotFoundMessage as N, NativeIntegrity as O, ADAPTER_FILTER_REASON as P, RelationalFieldMapper as Q, bucketSourceVerdict as R, ApplicationIntegrity as S, BaseDbAdapter as T, groupSourceVerdict as U, classifyQueryPath as V, guardAggregate as W, sortFieldNames as X, narrowerFilterOps as Y, unsupportedOperatorMessage as Z, isViewType as _, NoopLogger as _t, computeTableHash as a, describeFulltext as at, assertNoVersionWrites as b, snapshotToExistingColumns as c, TableMetadata as ct, computeForeignKeyDiff as d, aliasTargetOf as dt, FieldMappingStrategy as et, fkColumns as f, isBucketableField as ft, isAtscriptDbView as g, resolveCalendarBuckets as gt, AtscriptDbView as h, normalizeComputedSelect as ht, computeSchemaHash as i, defaultFulltextIndex as it, geoIndexNotFoundMessage as j, AtscriptDbReadable as k, snapshotToExistingTableOptions as l, isGeoIndexableType as lt, hasForeignKeyChanges as m, jsonValueAncestor as mt, isColumnTypeChanged as n, numericOperandProblem as nt, computeTableSnapshot as o, searchTermInteger as ot, fkKey as p, isJsonValueField as pt, guardPaths as q, canonicalizeQueryNode as r, INTEGER_REGEX_OP as rt, computeViewSnapshot as s, splitFulltextFields as st, computeColumnDiff as t, UniquSelect as tt, viewSnapshotSources as u, isGeoPointType as ut, AtscriptDbTable as v, ALL_VIEW_CAPABILITIES as w, decomposePatch as x, isVersionExemptPatch as y, canFilterLeaf as z };
9353
+ export { RelationalFieldMapper as $, AtscriptDbReadable as A, canFilterLeaf as B, ApplicationIntegrity as C, createFailureCollector as D, BaseDbAdapter as E, ADAPTER_FILTER_REASON as F, guardAggregate as G, classifyQueryPath as H, ENCRYPTED_REASON as I, guardPaths as J, guardFilter as K, acceptedOperatorsHint as L, geoIndexNotFoundMessage as M, searchIndexNotFoundMessage as N, IntegrityStrategy as O, vectorIndexNotFoundMessage as P, unsupportedOperatorMessage as Q, assertGeoPoint as R, decomposePatch as S, ALL_VIEW_CAPABILITIES as T, collectQueryPaths as U, checkHavingKeys as V, groupSourceVerdict as W, narrowerFilterOps as X, guardQuery as Y, sortFieldNames as Z, isAtscriptDbView as _, normalizeComputedSelect as _t, computeTableHash as a, defaultFulltextIndex as at, isVersionExemptPatch as b, serializeDefaultValue as c, splitFulltextFields as ct, viewSnapshotSources as d, isGeoPointType as dt, DocumentFieldMapper as et, computeForeignKeyDiff as f, relationalColumnName as ft, AtscriptDbView as g, jsonValueAncestor as gt, hasForeignKeyChanges as h, isJsonValueField as ht, computeSchemaHash as i, INTEGER_REGEX_OP as it, resolveDesignType as j, NativeIntegrity as k, snapshotToExistingColumns as l, TableMetadata as lt, fkKey as m, isBucketableField as mt, isColumnTypeChanged as n, UniquSelect as nt, computeTableSnapshot as o, describeFulltext as ot, fkColumns as p, aliasTargetOf as pt, guardPath as q, canonicalizeQueryNode as r, numericOperandProblem as rt, computeViewSnapshot as s, searchTermInteger as st, computeColumnDiff as t, FieldMappingStrategy as tt, snapshotToExistingTableOptions as u, isGeoIndexableType as ut, isViewType as v, resolveCalendarBuckets as vt, ALL_BUCKET_UNITS as w, assertNoVersionWrites as x, AtscriptDbTable as y, NoopLogger as yt, bucketSourceVerdict as z };