@atscript/db 0.1.149 → 0.1.150

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-DJiYlMXc.d.cts → buckets-24Ac4sIS.d.cts} +80 -5
  4. package/dist/{buckets-CNdTOnei.d.mts → buckets-XOz0Bw5x.d.mts} +80 -5
  5. package/dist/{column-diff-CUU4GvYg.cjs → column-diff-CKKBVDvx.cjs} +646 -341
  6. package/dist/{column-diff-Cp6ZoyRE.mjs → column-diff-CKLDWBbo.mjs} +522 -253
  7. package/dist/{fk-diff-BQ4krij8.d.cts → fk-diff-CCLiJ9sJ.d.cts} +2 -1
  8. package/dist/{fk-diff-DsaIijVX.d.mts → fk-diff-R8eU7gq-.d.mts} +2 -1
  9. package/dist/index.cjs +10 -3
  10. package/dist/index.d.cts +66 -4
  11. package/dist/index.d.mts +66 -4
  12. package/dist/index.mjs +3 -3
  13. package/dist/plugin.cjs +143 -36
  14. package/dist/plugin.mjs +138 -31
  15. package/dist/rel.d.cts +2 -2
  16. package/dist/rel.d.mts +2 -2
  17. package/dist/{relation-helpers-kX7jjgME.d.cts → relation-helpers-DjVJN2ng.d.cts} +1 -1
  18. package/dist/{relation-helpers-Ba0v49sn.d.mts → relation-helpers-H7UEVkHs.d.mts} +1 -1
  19. package/dist/search-fields-DBIZKwyb.mjs +117 -0
  20. package/dist/search-fields-DwrsJUC1.cjs +158 -0
  21. package/dist/shared.cjs +3 -1
  22. package/dist/shared.d.cts +19 -1
  23. package/dist/shared.d.mts +19 -1
  24. package/dist/shared.mjs +2 -2
  25. package/dist/sync.cjs +1 -1
  26. package/dist/sync.d.cts +2 -2
  27. package/dist/sync.d.mts +2 -2
  28. package/dist/sync.mjs +1 -1
  29. package/dist/{validation-utils-Dq0uZ7ef.mjs → validation-utils-DqZAH6aT.mjs} +44 -2
  30. package/dist/{validation-utils-Da2GjobR.cjs → validation-utils-debIYpq0.cjs} +56 -2
  31. package/package.json +6 -6
  32. package/dist/derived-rules-0sKn4f5C.mjs +0 -44
  33. package/dist/derived-rules-YstgIxG-.cjs +0 -67
  34. package/dist/numeric-operand-B1jKH7x5.mjs +0 -21
  35. package/dist/numeric-operand-DKfiRLYp.cjs +0 -26
@@ -1,12 +1,11 @@
1
1
  import { a as aggregateExpressionsNotSupported, l as spaceClosedError, n as CasMismatchError, r as DbError } from "./db-error-Az85UhTX.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-CyaZyb9I.mjs";
3
3
  import { B as slicePerGroup, D as createRelationFilterHost, E as containsRelationFilter, F as compositeKey, H as computeOf, I as findFKEntryForRelation, J as walkViewExpr, L as findFKForRelation, M as relGuardState, P as resolveRelationFilterTree, R as findRemoteFK, U as computedOperands, V as tableNameOf, _ as remapDeleteFkViolation, a as batchPatchNestedFrom, b as sameKey, c as batchReplaceNestedFrom, d as checkDepthOverflow, f as planNestedFromVia, g as enrichFkViolation, h as validateBatch, i as batchInsertNestedVia, j as noteRelationFilter, k as hasRelationOp, l as batchReplaceNestedTo, m as preValidateNestedFrom, n as batchInsertNestedFrom, p as planPatchNestedTo, q as viewExprNullable, r as batchInsertNestedTo, s as batchPatchNestedVia, t as applyPatchNestedTo, u as batchReplaceNestedVia, v as pkTupleKey, x as uniqueKeyTuple, y as rowMatchesKey } from "./nested-writer-CUBoq1ZO.mjs";
4
- import { i as isJsonLeafType, n as DERIVED_INCOMPATIBLE } from "./derived-rules-0sKn4f5C.mjs";
4
+ import { a as DERIVED_INCOMPATIBLE, n as searchMemberKind, r as numericTypeProblem, s as isJsonLeafType } from "./search-fields-DBIZKwyb.mjs";
5
5
  import { a as isPlainObject, i as isEmptyObject, n as findAncestorInSet, o as selfOrAncestor, r as getPath, t as deletePath } from "./object-TkiJQ-Dp.mjs";
6
6
  import { resolveAlias as resolveAlias$1 } from "./agg.mjs";
7
- import { t as numericTypeProblem } from "./numeric-operand-B1jKH7x5.mjs";
8
7
  import { t as NOT_DIMENSION_REASON } from "./aggregate-rules-jdPrxqWa.mjs";
9
- import { separateCas, separateFieldOps } from "./ops.mjs";
8
+ import { isDbFieldOp, separateCas, separateFieldOps } from "./ops.mjs";
10
9
  import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, n as buildPatchPartial, t as buildDbValidator } from "./validator-BTiIOTKP.mjs";
11
10
  import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
12
11
  import { ARITH_MAX_NODES, BUCKET_UNITS, isAggregateExpr, isAggregateOfExpr, isBucketExpr, isPrimitive, isRelationOp, isSelectArithExpr, resolveAlias, resolveBuckets } from "@uniqu/core";
@@ -396,6 +395,10 @@ function isGeoIndexableType(fieldType) {
396
395
  }
397
396
  return false;
398
397
  }
398
+ /** Runtime mirror of the `@db.column.version.exempt` placement errors. */
399
+ function rejectVersionExempt(path, why) {
400
+ throw new Error(`@db.column.version.exempt on "${path}": ${why}`);
401
+ }
399
402
  /**
400
403
  * Computed metadata for a database table or view.
401
404
  *
@@ -443,6 +446,15 @@ var TableMetadata = class {
443
446
  measures = [];
444
447
  /** Logical field name annotated with `@db.column.version`, if any. */
445
448
  versionField;
449
+ /** Paths annotated with `@db.column.version.exempt`, as declared (since 0.1.150). */
450
+ _versionExemptDeclared = /* @__PURE__ */ new Set();
451
+ /**
452
+ * Version-exempt paths (since 0.1.150): the declared ones plus every object
453
+ * whose direct children are all exempt (upward closure). A path is exempt
454
+ * when it, or an ancestor, is in this set — see {@link isVersionExemptPath}.
455
+ * Empty when the table declares no `@db.column.version`.
456
+ */
457
+ versionExemptPaths = /* @__PURE__ */ new Set();
446
458
  /** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
447
459
  quantityRefByField = /* @__PURE__ */ new Map();
448
460
  /** Logical paths annotated with `@db.encrypted` — stored as one opaque ciphertext column. */
@@ -599,6 +611,7 @@ var TableMetadata = class {
599
611
  ...this.originalMetaIdFields
600
612
  ]);
601
613
  this._finalizeIndexes();
614
+ this._finalizeVersionExempt();
602
615
  this._collateMap.clear();
603
616
  this._columnFromMap.clear();
604
617
  this.jsonFields.clear();
@@ -700,6 +713,15 @@ var TableMetadata = class {
700
713
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
701
714
  this._addIndexField("unique", name, fieldName);
702
715
  }
716
+ if (metadata.has("db.writeOnly") && (metadata.has("db.index.fulltext") || metadata.has("db.column.searchable"))) {
717
+ const which = metadata.has("db.index.fulltext") ? "@db.index.fulltext" : "@db.column.searchable";
718
+ throw new Error(`@db.writeOnly cannot coexist with ${which} on "${fieldName}" — search results would reveal the sealed value`);
719
+ }
720
+ const searchKind = searchMemberKind(fieldType);
721
+ if (typeof searchKind === "object") {
722
+ if (metadata.has("db.index.fulltext")) throw new Error(`@db.index.fulltext on "${fieldName}" ${searchKind.problem} — a fulltext member must be a string or an integer`);
723
+ if (metadata.has("db.column.searchable")) throw new Error(`@db.column.searchable on "${fieldName}" ${searchKind.problem} — a searchable column must be a string or an integer`);
724
+ }
703
725
  for (const index of metadata.get("db.index.fulltext") || []) {
704
726
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
705
727
  const weight = index !== true && typeof index === "object" ? index?.weight : void 0;
@@ -727,6 +749,7 @@ var TableMetadata = class {
727
749
  if (!hasExplicitIndex) this._addIndexField("plain", fieldName, fieldName);
728
750
  }
729
751
  if (metadata.has("db.column.measure")) this.measures.push(fieldName);
752
+ if (metadata.has("db.column.version.exempt")) this._versionExemptDeclared.add(fieldName);
730
753
  if (metadata.has("db.column.version")) if (this.versionField !== void 0) logger.warn(`@db.column.version declared on multiple fields ("${this.versionField}" and "${fieldName}") — only one is allowed; using "${this.versionField}"`);
731
754
  else {
732
755
  this.versionField = fieldName;
@@ -737,6 +760,55 @@ var TableMetadata = class {
737
760
  }
738
761
  }
739
762
  /**
763
+ * Validates the `@db.column.version.exempt` placements (E1–E5, the runtime
764
+ * mirror of the compile-time check, so pre-compiled models fail fast) and
765
+ * computes {@link versionExemptPaths} with its upward closure. Runs after
766
+ * `_applyOverrides` (the primary keys are final) and before `jsonFields` is
767
+ * released. A table without a version column ignores the annotation.
768
+ */
769
+ _finalizeVersionExempt() {
770
+ if (this._versionExemptDeclared.size === 0) return;
771
+ for (const path of this._versionExemptDeclared) {
772
+ if (path === this.versionField) rejectVersionExempt(path, "cannot mark the version column itself");
773
+ if (this.primaryKeys.includes(path)) rejectVersionExempt(path, "a primary key identifies the row and is never patched");
774
+ if (this.navFields.has(path)) rejectVersionExempt(path, "a navigation field has no column here");
775
+ let pos = path.length;
776
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
777
+ const ancestor = path.slice(0, pos);
778
+ const node = this.flatMap.get(ancestor);
779
+ if (node?.metadata.has("db.json")) rejectVersionExempt(path, `mark the @db.json field "${ancestor}" itself — a JSON column is written as one value`);
780
+ if (node?.type.kind === "array") rejectVersionExempt(path, `mark the array field "${ancestor}" itself — array elements are not separate columns`);
781
+ }
782
+ }
783
+ if (this.versionField === void 0) return;
784
+ const exempt = new Set(this._versionExemptDeclared);
785
+ const objects = [...this.flatMap.keys()].filter((p) => {
786
+ const node = this.flatMap.get(p);
787
+ return node.type.kind === "object" && !node.metadata.has("db.json");
788
+ }).toSorted((a, b) => b.split(".").length - a.split(".").length);
789
+ for (const obj of objects) {
790
+ if (exempt.has(obj)) continue;
791
+ const prefix = `${obj}.`;
792
+ let children = 0;
793
+ let all = true;
794
+ for (const key of this.flatMap.keys()) {
795
+ if (!key.startsWith(prefix) || key.indexOf(".", prefix.length) !== -1) continue;
796
+ if (this.navFields.has(key) || this.ignoredFields.has(key)) continue;
797
+ children++;
798
+ if (!exempt.has(key)) {
799
+ all = false;
800
+ break;
801
+ }
802
+ }
803
+ if (children > 0 && all) exempt.add(obj);
804
+ }
805
+ this.versionExemptPaths = exempt;
806
+ }
807
+ /** Whether `path` — or an ancestor of it — is version-exempt (since 0.1.150). */
808
+ isVersionExemptPath(path) {
809
+ return selfOrAncestor(path, this.versionExemptPaths) !== void 0;
810
+ }
811
+ /**
740
812
  * Build-time diagnostics for `@db.encrypted` (§6 of the field-encryption
741
813
  * spec). Mirrors the compile-time AnnotationSpec validation so models built
742
814
  * from pre-compiled types still fail fast.
@@ -1070,8 +1142,28 @@ var TableMetadata = class {
1070
1142
  };
1071
1143
  }
1072
1144
  }
1145
+ /**
1146
+ * Flags the integer members of fulltext indexes (matched by exact number,
1147
+ * never part of the physical text index) and enforces that each is
1148
+ * index-backed — the equality branch must not scan. Runs while index field
1149
+ * names are still logical.
1150
+ */
1151
+ _resolveIntegerFulltextMembers() {
1152
+ for (const index of this.indexes.values()) {
1153
+ if (index.type !== "fulltext") continue;
1154
+ for (const field of index.fields) {
1155
+ const ftype = this.flatMap.get(field.name);
1156
+ if (!ftype || searchMemberKind(ftype) !== "integer") continue;
1157
+ const where = `@db.index.fulltext on the integer field "${field.name}"`;
1158
+ if (findAncestorInSet(field.name, this.jsonFields) !== void 0) throw new Error(`${where} inside a JSON value is not supported`);
1159
+ if (!(field.name === "_id" || this.originalMetaIdFields[0] === field.name || this.uniqueProps.has(field.name) || [...this.indexes.values()].some((other) => (other.type === "plain" || other.type === "unique") && other.fields[0]?.name === field.name))) throw new Error(`${where} needs an index for its exact-number match — make it the primary key (first @meta.id) or the first field of a @db.index.plain / @db.index.unique`);
1160
+ field.integer = true;
1161
+ }
1162
+ }
1163
+ }
1073
1164
  _finalizeIndexes() {
1074
1165
  for (const index of this.indexes.values()) if (index.type === "unique" && index.fields.length === 1) this.uniqueProps.add(index.fields[0].name);
1166
+ this._resolveIntegerFulltextMembers();
1075
1167
  for (const index of this.indexes.values()) for (const field of index.fields) {
1076
1168
  const ftype = this.flatMap.get(field.name);
1077
1169
  if (ftype) {
@@ -1143,15 +1235,343 @@ var TableMetadata = class {
1143
1235
  const selected = this.getIdentifications().find((id) => id.source !== "primaryKey" && (requestedName === void 0 || id.source === requestedName));
1144
1236
  this.preferredId = selected ? [...selected.fields] : [...this.primaryKeys];
1145
1237
  }
1146
- };
1147
- /** `TDbFieldMeta.computed` of a top-level `@db.compute` view field (since 0.1.147). */
1148
- function computedMeta(rootType, path) {
1149
- if (path.includes(".")) return void 0;
1150
- const computed = computedOperands(rootType, path);
1151
- return computed ? {
1152
- operands: Object.freeze(computed.operands),
1153
- via: Object.freeze(computed.via)
1154
- } : void 0;
1238
+ };
1239
+ /** `TDbFieldMeta.computed` of a top-level `@db.compute` view field (since 0.1.147). */
1240
+ function computedMeta(rootType, path) {
1241
+ if (path.includes(".")) return void 0;
1242
+ const computed = computedOperands(rootType, path);
1243
+ return computed ? {
1244
+ operands: Object.freeze(computed.operands),
1245
+ via: Object.freeze(computed.via)
1246
+ } : void 0;
1247
+ }
1248
+ //#endregion
1249
+ //#region src/shared/search-term.ts
1250
+ /**
1251
+ * Internal filter operator (since 0.1.150): a regular expression over the
1252
+ * DECIMAL TEXT of an integer column. The core rewrites a caller's `$regex`
1253
+ * (or bare `RegExp`) on an integer field into it at translation time;
1254
+ * callers can never send it (the filter value guard refuses it).
1255
+ */
1256
+ const INTEGER_REGEX_OP = "$integerRegex";
1257
+ const INTEGER_TERM_RE = /^-?(?:0|[1-9][0-9]*)$/;
1258
+ /**
1259
+ * The whole number a `$search` term denotes, or `undefined` (since 0.1.150).
1260
+ * The trimmed term must be a plain integer literal — ASCII digits with an
1261
+ * optional leading `-`, no `+`, no leading zeros (except `0` itself), no
1262
+ * `-0`, no separators / decimals / exponent — within the safe-integer range.
1263
+ * Only the whole term counts (`"invoice 2946"` has no number).
1264
+ */
1265
+ function searchTermInteger(term) {
1266
+ const t = term.trim();
1267
+ if (!INTEGER_TERM_RE.test(t) || t === "-0") return void 0;
1268
+ const n = Number(t);
1269
+ return Number.isSafeInteger(n) ? n : void 0;
1270
+ }
1271
+ /**
1272
+ * Splits a fulltext index into the members that belong to the physical text
1273
+ * index and the integer members matched by exact number (since 0.1.150).
1274
+ */
1275
+ function splitFulltextFields(index) {
1276
+ const text = [];
1277
+ const integer = [];
1278
+ for (const f of index.fields) (f.integer ? integer : text).push(f);
1279
+ return {
1280
+ text,
1281
+ integer
1282
+ };
1283
+ }
1284
+ /**
1285
+ * `/meta` description of a fulltext index: the adapter's text-index label over
1286
+ * the text members, then the exact-number members (since 0.1.150).
1287
+ */
1288
+ function describeFulltext(index, textLabel) {
1289
+ const { text, integer } = splitFulltextFields(index);
1290
+ return [text.length > 0 ? textLabel(text.map((f) => f.name).join(", ")) : "", integer.map((f) => `exact number on ${f.name}`).join(" + ")].filter(Boolean).join(" + ");
1291
+ }
1292
+ /**
1293
+ * The fulltext index that answers a search naming none (since 0.1.150): the
1294
+ * first one with at least one TEXT member, else the first one (an index of
1295
+ * integer members only answers exact-number terms).
1296
+ */
1297
+ function defaultFulltextIndex(indexes) {
1298
+ return indexes.find((i) => splitFulltextFields(i).text.length > 0) ?? indexes[0];
1299
+ }
1300
+ //#endregion
1301
+ //#region src/query/filter-values.ts
1302
+ const OPAQUE = {
1303
+ kinds: new Set(["any"]),
1304
+ timestamp: false
1305
+ };
1306
+ const NUMBER = {
1307
+ kinds: new Set(["number"]),
1308
+ timestamp: false
1309
+ };
1310
+ const INTEGER = {
1311
+ kinds: new Set(["integer"]),
1312
+ timestamp: false
1313
+ };
1314
+ const STRING = {
1315
+ kinds: new Set(["string"]),
1316
+ timestamp: false
1317
+ };
1318
+ function collectKinds(type, kinds, out, depth = 0) {
1319
+ const def = type?.type;
1320
+ const metadata = type?.metadata;
1321
+ if (!def || depth > 8) {
1322
+ kinds.add("any");
1323
+ return;
1324
+ }
1325
+ switch (def.kind) {
1326
+ case "": switch (def.designType) {
1327
+ case "string":
1328
+ kinds.add("string");
1329
+ return;
1330
+ case "number": {
1331
+ const timestamp = def.tags?.has("timestamp") === true;
1332
+ if (timestamp) out.timestamp = true;
1333
+ const integer = timestamp || def.tags?.has("int") === true || metadata?.has?.("expect.int") === true;
1334
+ kinds.add(integer ? "integer" : "number");
1335
+ return;
1336
+ }
1337
+ case "decimal":
1338
+ kinds.add("decimal");
1339
+ return;
1340
+ case "boolean":
1341
+ kinds.add("boolean");
1342
+ return;
1343
+ case "null":
1344
+ case "undefined":
1345
+ case "never": return;
1346
+ default:
1347
+ kinds.add("any");
1348
+ return;
1349
+ }
1350
+ case "union":
1351
+ for (const item of def.items ?? []) collectKinds(item, kinds, out, depth + 1);
1352
+ return;
1353
+ case "array":
1354
+ collectKinds(def.of, kinds, out, depth + 1);
1355
+ return;
1356
+ default: kinds.add("any");
1357
+ }
1358
+ }
1359
+ const typeCache = /* @__PURE__ */ new WeakMap();
1360
+ /**
1361
+ * The value kinds a filter on `fd` accepts (cached per descriptor). A leaf
1362
+ * inside a JSON value (a `@db.json` object or an array, addressable on
1363
+ * nested-object adapters) is opaque: its contents are not schema-enforced.
1364
+ */
1365
+ function valueTypeOf(meta, fd) {
1366
+ let vt = typeCache.get(fd);
1367
+ if (vt) return vt;
1368
+ if (jsonValueAncestor(fd.path, meta.jsonValueParents) !== void 0 || fd.encrypted || fd.isGeoPoint || fd.designType === "json" || fd.designType === "object" || !fd.type) vt = OPAQUE;
1369
+ else {
1370
+ const kinds = /* @__PURE__ */ new Set();
1371
+ const out = { timestamp: false };
1372
+ collectKinds(fd.type, kinds, out);
1373
+ const metadata = fd.type.metadata;
1374
+ if ((fd.defaultValue?.kind === "fn" && fd.defaultValue.fn !== "uuid" || metadata?.has?.("db.agg.count") === true || metadata?.has?.("db.agg.countDistinct") === true) && kinds.delete("number")) kinds.add("integer");
1375
+ vt = kinds.size === 0 ? OPAQUE : {
1376
+ kinds,
1377
+ timestamp: out.timestamp,
1378
+ integerText: searchMemberKind(fd.type) === "integer"
1379
+ };
1380
+ }
1381
+ typeCache.set(fd, vt);
1382
+ return vt;
1383
+ }
1384
+ /** Whether `$regex` on `fd` matches the decimal text of an integer (since 0.1.150). */
1385
+ function isIntegerTextField(meta, fd) {
1386
+ return valueTypeOf(meta, fd).integerText === true;
1387
+ }
1388
+ /** A decimal literal (`5`, `-1.5`, `.5`, `1e3`), surrounding blanks allowed — no hex, no `Infinity`. */
1389
+ const NUMERIC_RE = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?\s*$/i;
1390
+ /** An integer literal (`5`, `-12`), surrounding blanks allowed. */
1391
+ const INTEGER_RE = /^\s*[+-]?\d+\s*$/;
1392
+ function acceptsScalar(kind, value) {
1393
+ switch (kind) {
1394
+ case "any": return true;
1395
+ case "string": return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint";
1396
+ case "number":
1397
+ case "decimal": return typeof value === "number" && Number.isFinite(value) || typeof value === "bigint" || typeof value === "string" && NUMERIC_RE.test(value);
1398
+ case "integer": return typeof value === "number" && Number.isInteger(value) || typeof value === "bigint" || typeof value === "string" && INTEGER_RE.test(value);
1399
+ default: return typeof value === "boolean" || value === 0 || value === 1;
1400
+ }
1401
+ }
1402
+ /** The first element of `value` (itself when not an array) `vt` rejects, or `undefined`. */
1403
+ function rejectedValue(vt, value) {
1404
+ if (vt.kinds.has("any") || value === null || value === void 0) return void 0;
1405
+ if (Array.isArray(value)) {
1406
+ for (const item of value) {
1407
+ const bad = rejectedValue(vt, item);
1408
+ if (bad) return bad;
1409
+ }
1410
+ return;
1411
+ }
1412
+ if (typeof value === "object" && !isPlainObject(value)) return void 0;
1413
+ for (const kind of vt.kinds) if (acceptsScalar(kind, value)) return void 0;
1414
+ return { value };
1415
+ }
1416
+ const KIND_LABEL = {
1417
+ string: "a string",
1418
+ number: "a number",
1419
+ integer: "an integer",
1420
+ decimal: "a decimal (number or numeric string)",
1421
+ boolean: "a boolean"
1422
+ };
1423
+ function expectedOf(vt) {
1424
+ const labels = [];
1425
+ for (const kind of vt.kinds) {
1426
+ if (kind === "any") continue;
1427
+ labels.push(kind === "integer" && vt.timestamp ? "an integer (epoch milliseconds)" : KIND_LABEL[kind]);
1428
+ }
1429
+ return labels.join(" or ");
1430
+ }
1431
+ function describeValue(value) {
1432
+ if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
1433
+ if (isPlainObject(value)) return "an object";
1434
+ return String(value);
1435
+ }
1436
+ function valueError(path, op, message) {
1437
+ return new DbError("INVALID_QUERY", [{
1438
+ path,
1439
+ message: `Invalid filter value for "${path}"${op ? ` (${op})` : ""}: ${message}`
1440
+ }]);
1441
+ }
1442
+ function holdsStrings(vt) {
1443
+ return vt.kinds.has("string") || vt.kinds.has("any") || vt.integerText === true;
1444
+ }
1445
+ function checkRegex(path, vt, op, pattern) {
1446
+ if (!holdsStrings(vt)) throw valueError(path, op, `a pattern match needs a string or integer field, "${path}" holds ${expectedOf(vt)}`);
1447
+ if (typeof pattern !== "string" && !(pattern instanceof RegExp)) throw valueError(path, op, `expected a regular expression, got ${describeValue(pattern)}`);
1448
+ }
1449
+ function checkValue(path, vt, op, value) {
1450
+ if (value instanceof RegExp) {
1451
+ checkRegex(path, vt, op ?? "RegExp", value);
1452
+ return;
1453
+ }
1454
+ const bad = rejectedValue(vt, value);
1455
+ if (bad) throw valueError(path, op, `expected ${expectedOf(vt)}, got ${describeValue(bad.value)}`);
1456
+ }
1457
+ /** Operators whose operand is compared with the field's values. */
1458
+ const COMPARE_OPS = new Set([
1459
+ "$eq",
1460
+ "$ne",
1461
+ "$gt",
1462
+ "$gte",
1463
+ "$lt",
1464
+ "$lte",
1465
+ "$in",
1466
+ "$nin"
1467
+ ]);
1468
+ /** Checks one filter entry's value (a bare value or an operator map) against `vt`. */
1469
+ function checkEntry(path, vt, value) {
1470
+ if (vt.kinds.has("any")) return;
1471
+ if (!isPlainObject(value)) {
1472
+ checkValue(path, vt, void 0, value);
1473
+ return;
1474
+ }
1475
+ for (const [op, operand] of Object.entries(value)) if (op === "$regex") checkRegex(path, vt, op, operand);
1476
+ else if (COMPARE_OPS.has(op)) checkValue(path, vt, op, operand);
1477
+ }
1478
+ /**
1479
+ * Walks `filter` (through `$and` / `$or` / `$not`; relational predicates are
1480
+ * the related table's) and checks every entry whose key `typeOf` knows.
1481
+ */
1482
+ function walkFilterValues(filter, typeOf) {
1483
+ if (!isPlainObject(filter)) return;
1484
+ for (const [key, value] of Object.entries(filter)) {
1485
+ if (key === "$and" || key === "$or") {
1486
+ if (Array.isArray(value)) for (const child of value) walkFilterValues(child, typeOf);
1487
+ continue;
1488
+ }
1489
+ if (key === "$not") {
1490
+ walkFilterValues(value, typeOf);
1491
+ continue;
1492
+ }
1493
+ if (key.startsWith("$") || hasRelationOp(value)) continue;
1494
+ if (isPlainObject(value) && "$integerRegex" in value) throw valueError(key, INTEGER_REGEX_OP, "is internal — use $regex");
1495
+ const vt = typeOf(key);
1496
+ if (vt) checkEntry(key, vt, value);
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Rejects (`INVALID_QUERY`, `path` = the field) a filter value that cannot
1501
+ * denote its field's declared type — see the module notes for the accepted
1502
+ * forms. Unknown paths are skipped (the path guard owns them).
1503
+ */
1504
+ function guardFilterValues(meta, filter) {
1505
+ if (!filter) return;
1506
+ walkFilterValues(filter, (key) => {
1507
+ const fd = meta.descriptorByPath.get(key);
1508
+ return fd ? valueTypeOf(meta, fd) : void 0;
1509
+ });
1510
+ }
1511
+ /**
1512
+ * `$having` values: an aggregate alias is a number (`count`,
1513
+ * `countDistinct`, `sum`, `avg`, an expression) or its source field's type
1514
+ * (`min` / `max`, `first` / `last`),
1515
+ * a calendar-bucket alias a string label, any other key a `$groupBy`
1516
+ * field's own type.
1517
+ */
1518
+ function guardHavingValues(meta, controls) {
1519
+ if (!controls?.$having) return;
1520
+ const aliases = /* @__PURE__ */ new Map();
1521
+ if (Array.isArray(controls.$select)) {
1522
+ for (const item of controls.$select) if (isAggregateOfExpr(item) || isSelectArithExpr(item)) aliases.set(item.$as, NUMBER);
1523
+ else if (isAggregateExpr(item)) {
1524
+ const fd = SOURCE_VALUE_FNS.has(item.$fn) ? meta.descriptorByPath.get(item.$field) : void 0;
1525
+ const counts = item.$fn === "count" || item.$fn === "countDistinct";
1526
+ aliases.set(resolveAlias$1(item), fd ? valueTypeOf(meta, fd) : counts ? INTEGER : NUMBER);
1527
+ } else if (isBucketExpr(item)) aliases.set(resolveAlias$1(item), STRING);
1528
+ }
1529
+ walkFilterValues(controls.$having, (key) => {
1530
+ const alias = aliases.get(key);
1531
+ if (alias) return alias;
1532
+ const fd = meta.descriptorByPath.get(key);
1533
+ return fd ? valueTypeOf(meta, fd) : void 0;
1534
+ });
1535
+ }
1536
+ //#endregion
1537
+ //#region src/query/integer-regex.ts
1538
+ /** Rewrites one field entry; returns the same value when it has no pattern match. */
1539
+ function rewriteEntry(value) {
1540
+ if (value instanceof RegExp) return { [INTEGER_REGEX_OP]: value };
1541
+ if (isPlainObject(value) && "$regex" in value) {
1542
+ const out = {};
1543
+ for (const [op, operand] of Object.entries(value)) out[op === "$regex" ? INTEGER_REGEX_OP : op] = operand;
1544
+ return out;
1545
+ }
1546
+ return value;
1547
+ }
1548
+ /**
1549
+ * `$regex` (and a bare `RegExp`) on integer search members →
1550
+ * {@link INTEGER_REGEX_OP}, on the LOGICAL filter. Copy-on-write: the very
1551
+ * same object comes back when nothing changed. Relational predicates are
1552
+ * rewritten by the related table when it translates them.
1553
+ */
1554
+ function rewriteIntegerRegex(filter, meta) {
1555
+ if (!isPlainObject(filter)) return filter;
1556
+ let out;
1557
+ for (const [key, value] of Object.entries(filter)) {
1558
+ let next = value;
1559
+ if (key === "$and" || key === "$or") {
1560
+ if (Array.isArray(value)) {
1561
+ const mapped = value.map((f) => rewriteIntegerRegex(f, meta));
1562
+ if (mapped.some((m, i) => m !== value[i])) next = mapped;
1563
+ }
1564
+ } else if (key === "$not") next = rewriteIntegerRegex(value, meta);
1565
+ else if (!key.startsWith("$") && !meta.navFields.has(key)) {
1566
+ const fd = meta.descriptorByPath.get(key);
1567
+ if (fd && isIntegerTextField(meta, fd)) next = rewriteEntry(value);
1568
+ }
1569
+ if (next !== value) {
1570
+ out ??= { ...filter };
1571
+ out[key] = next;
1572
+ }
1573
+ }
1574
+ return out ?? filter;
1155
1575
  }
1156
1576
  //#endregion
1157
1577
  //#region src/query/aggregate-expr.ts
@@ -1787,6 +2207,7 @@ var FieldMappingStrategy = class {
1787
2207
  * translate predicate operands at deeper levels).
1788
2208
  */
1789
2209
  translateFilter(filter, meta, depth = 0) {
2210
+ filter = rewriteIntegerRegex(filter, meta);
1790
2211
  const has = containsRelationFilter(filter);
1791
2212
  const resolved = has ? resolveRelationFilterTree(filter, meta, depth) : filter;
1792
2213
  return this.noteTranslated(filter, this.translateResolvedFilter(resolved, meta), has);
@@ -1921,7 +2342,8 @@ var FieldMappingStrategy = class {
1921
2342
  if (!isPlainObject(value)) return fmt(value);
1922
2343
  const ops = value;
1923
2344
  const formatted = {};
1924
- 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));
2345
+ for (const [op, opVal] of Object.entries(ops)) if (op === "$integerRegex") formatted[op] = opVal;
2346
+ else if ((op === "$in" || op === "$nin") && Array.isArray(opVal)) formatted[op] = opVal.map((v) => v === null || v === void 0 ? v : fmt(v));
1925
2347
  else if (op.startsWith("$") && opVal !== null && opVal !== void 0) formatted[op] = fmt(opVal);
1926
2348
  else formatted[op] = opVal;
1927
2349
  return formatted;
@@ -2118,7 +2540,7 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2118
2540
  return result;
2119
2541
  }
2120
2542
  translateQuery(query, meta) {
2121
- const logical = query.filter;
2543
+ const logical = rewriteIntegerRegex(query.filter, meta);
2122
2544
  const has = containsRelationFilter(logical);
2123
2545
  const filter = has ? resolveRelationFilterTree(logical, meta, 0) : logical;
2124
2546
  if (!meta.requiresMappings) {
@@ -2294,236 +2716,6 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
2294
2716
  }
2295
2717
  };
2296
2718
  //#endregion
2297
- //#region src/query/filter-values.ts
2298
- const OPAQUE = {
2299
- kinds: new Set(["any"]),
2300
- timestamp: false
2301
- };
2302
- const NUMBER = {
2303
- kinds: new Set(["number"]),
2304
- timestamp: false
2305
- };
2306
- const INTEGER = {
2307
- kinds: new Set(["integer"]),
2308
- timestamp: false
2309
- };
2310
- const STRING = {
2311
- kinds: new Set(["string"]),
2312
- timestamp: false
2313
- };
2314
- function collectKinds(type, kinds, out, depth = 0) {
2315
- const def = type?.type;
2316
- const metadata = type?.metadata;
2317
- if (!def || depth > 8) {
2318
- kinds.add("any");
2319
- return;
2320
- }
2321
- switch (def.kind) {
2322
- case "": switch (def.designType) {
2323
- case "string":
2324
- kinds.add("string");
2325
- return;
2326
- case "number": {
2327
- const timestamp = def.tags?.has("timestamp") === true;
2328
- if (timestamp) out.timestamp = true;
2329
- const integer = timestamp || def.tags?.has("int") === true || metadata?.has?.("expect.int") === true;
2330
- kinds.add(integer ? "integer" : "number");
2331
- return;
2332
- }
2333
- case "decimal":
2334
- kinds.add("decimal");
2335
- return;
2336
- case "boolean":
2337
- kinds.add("boolean");
2338
- return;
2339
- case "null":
2340
- case "undefined":
2341
- case "never": return;
2342
- default:
2343
- kinds.add("any");
2344
- return;
2345
- }
2346
- case "union":
2347
- for (const item of def.items ?? []) collectKinds(item, kinds, out, depth + 1);
2348
- return;
2349
- case "array":
2350
- collectKinds(def.of, kinds, out, depth + 1);
2351
- return;
2352
- default: kinds.add("any");
2353
- }
2354
- }
2355
- const typeCache = /* @__PURE__ */ new WeakMap();
2356
- /**
2357
- * The value kinds a filter on `fd` accepts (cached per descriptor). A leaf
2358
- * inside a JSON value (a `@db.json` object or an array, addressable on
2359
- * nested-object adapters) is opaque: its contents are not schema-enforced.
2360
- */
2361
- function valueTypeOf(meta, fd) {
2362
- let vt = typeCache.get(fd);
2363
- if (vt) return vt;
2364
- if (jsonValueAncestor(fd.path, meta.jsonValueParents) !== void 0 || fd.encrypted || fd.isGeoPoint || fd.designType === "json" || fd.designType === "object" || !fd.type) vt = OPAQUE;
2365
- else {
2366
- const kinds = /* @__PURE__ */ new Set();
2367
- const out = { timestamp: false };
2368
- collectKinds(fd.type, kinds, out);
2369
- const metadata = fd.type.metadata;
2370
- if ((fd.defaultValue?.kind === "fn" && fd.defaultValue.fn !== "uuid" || metadata?.has?.("db.agg.count") === true || metadata?.has?.("db.agg.countDistinct") === true) && kinds.delete("number")) kinds.add("integer");
2371
- vt = kinds.size === 0 ? OPAQUE : {
2372
- kinds,
2373
- timestamp: out.timestamp
2374
- };
2375
- }
2376
- typeCache.set(fd, vt);
2377
- return vt;
2378
- }
2379
- /** A decimal literal (`5`, `-1.5`, `.5`, `1e3`), surrounding blanks allowed — no hex, no `Infinity`. */
2380
- const NUMERIC_RE = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?\s*$/i;
2381
- /** An integer literal (`5`, `-12`), surrounding blanks allowed. */
2382
- const INTEGER_RE = /^\s*[+-]?\d+\s*$/;
2383
- function acceptsScalar(kind, value) {
2384
- switch (kind) {
2385
- case "any": return true;
2386
- case "string": return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint";
2387
- case "number":
2388
- case "decimal": return typeof value === "number" && Number.isFinite(value) || typeof value === "bigint" || typeof value === "string" && NUMERIC_RE.test(value);
2389
- case "integer": return typeof value === "number" && Number.isInteger(value) || typeof value === "bigint" || typeof value === "string" && INTEGER_RE.test(value);
2390
- default: return typeof value === "boolean" || value === 0 || value === 1;
2391
- }
2392
- }
2393
- /** The first element of `value` (itself when not an array) `vt` rejects, or `undefined`. */
2394
- function rejectedValue(vt, value) {
2395
- if (vt.kinds.has("any") || value === null || value === void 0) return void 0;
2396
- if (Array.isArray(value)) {
2397
- for (const item of value) {
2398
- const bad = rejectedValue(vt, item);
2399
- if (bad) return bad;
2400
- }
2401
- return;
2402
- }
2403
- if (typeof value === "object" && !isPlainObject(value)) return void 0;
2404
- for (const kind of vt.kinds) if (acceptsScalar(kind, value)) return void 0;
2405
- return { value };
2406
- }
2407
- const KIND_LABEL = {
2408
- string: "a string",
2409
- number: "a number",
2410
- integer: "an integer",
2411
- decimal: "a decimal (number or numeric string)",
2412
- boolean: "a boolean"
2413
- };
2414
- function expectedOf(vt) {
2415
- const labels = [];
2416
- for (const kind of vt.kinds) {
2417
- if (kind === "any") continue;
2418
- labels.push(kind === "integer" && vt.timestamp ? "an integer (epoch milliseconds)" : KIND_LABEL[kind]);
2419
- }
2420
- return labels.join(" or ");
2421
- }
2422
- function describeValue(value) {
2423
- if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
2424
- if (isPlainObject(value)) return "an object";
2425
- return String(value);
2426
- }
2427
- function valueError(path, op, message) {
2428
- return new DbError("INVALID_QUERY", [{
2429
- path,
2430
- message: `Invalid filter value for "${path}"${op ? ` (${op})` : ""}: ${message}`
2431
- }]);
2432
- }
2433
- function holdsStrings(vt) {
2434
- return vt.kinds.has("string") || vt.kinds.has("any");
2435
- }
2436
- function checkRegex(path, vt, op, pattern) {
2437
- if (!holdsStrings(vt)) throw valueError(path, op, `a pattern match needs a string field, "${path}" holds ${expectedOf(vt)}`);
2438
- if (typeof pattern !== "string" && !(pattern instanceof RegExp)) throw valueError(path, op, `expected a regular expression, got ${describeValue(pattern)}`);
2439
- }
2440
- function checkValue(path, vt, op, value) {
2441
- if (value instanceof RegExp) {
2442
- checkRegex(path, vt, op ?? "RegExp", value);
2443
- return;
2444
- }
2445
- const bad = rejectedValue(vt, value);
2446
- if (bad) throw valueError(path, op, `expected ${expectedOf(vt)}, got ${describeValue(bad.value)}`);
2447
- }
2448
- /** Operators whose operand is compared with the field's values. */
2449
- const COMPARE_OPS = new Set([
2450
- "$eq",
2451
- "$ne",
2452
- "$gt",
2453
- "$gte",
2454
- "$lt",
2455
- "$lte",
2456
- "$in",
2457
- "$nin"
2458
- ]);
2459
- /** Checks one filter entry's value (a bare value or an operator map) against `vt`. */
2460
- function checkEntry(path, vt, value) {
2461
- if (vt.kinds.has("any")) return;
2462
- if (!isPlainObject(value)) {
2463
- checkValue(path, vt, void 0, value);
2464
- return;
2465
- }
2466
- for (const [op, operand] of Object.entries(value)) if (op === "$regex") checkRegex(path, vt, op, operand);
2467
- else if (COMPARE_OPS.has(op)) checkValue(path, vt, op, operand);
2468
- }
2469
- /**
2470
- * Walks `filter` (through `$and` / `$or` / `$not`; relational predicates are
2471
- * the related table's) and checks every entry whose key `typeOf` knows.
2472
- */
2473
- function walkFilterValues(filter, typeOf) {
2474
- if (!isPlainObject(filter)) return;
2475
- for (const [key, value] of Object.entries(filter)) {
2476
- if (key === "$and" || key === "$or") {
2477
- if (Array.isArray(value)) for (const child of value) walkFilterValues(child, typeOf);
2478
- continue;
2479
- }
2480
- if (key === "$not") {
2481
- walkFilterValues(value, typeOf);
2482
- continue;
2483
- }
2484
- if (key.startsWith("$") || hasRelationOp(value)) continue;
2485
- const vt = typeOf(key);
2486
- if (vt) checkEntry(key, vt, value);
2487
- }
2488
- }
2489
- /**
2490
- * Rejects (`INVALID_QUERY`, `path` = the field) a filter value that cannot
2491
- * denote its field's declared type — see the module notes for the accepted
2492
- * forms. Unknown paths are skipped (the path guard owns them).
2493
- */
2494
- function guardFilterValues(meta, filter) {
2495
- if (!filter) return;
2496
- walkFilterValues(filter, (key) => {
2497
- const fd = meta.descriptorByPath.get(key);
2498
- return fd ? valueTypeOf(meta, fd) : void 0;
2499
- });
2500
- }
2501
- /**
2502
- * `$having` values: an aggregate alias is a number (`count`,
2503
- * `countDistinct`, `sum`, `avg`, an expression) or its source field's type
2504
- * (`min` / `max`, `first` / `last`),
2505
- * a calendar-bucket alias a string label, any other key a `$groupBy`
2506
- * field's own type.
2507
- */
2508
- function guardHavingValues(meta, controls) {
2509
- if (!controls?.$having) return;
2510
- const aliases = /* @__PURE__ */ new Map();
2511
- if (Array.isArray(controls.$select)) {
2512
- for (const item of controls.$select) if (isAggregateOfExpr(item) || isSelectArithExpr(item)) aliases.set(item.$as, NUMBER);
2513
- else if (isAggregateExpr(item)) {
2514
- const fd = SOURCE_VALUE_FNS.has(item.$fn) ? meta.descriptorByPath.get(item.$field) : void 0;
2515
- const counts = item.$fn === "count" || item.$fn === "countDistinct";
2516
- aliases.set(resolveAlias$1(item), fd ? valueTypeOf(meta, fd) : counts ? INTEGER : NUMBER);
2517
- } else if (isBucketExpr(item)) aliases.set(resolveAlias$1(item), STRING);
2518
- }
2519
- walkFilterValues(controls.$having, (key) => {
2520
- const alias = aliases.get(key);
2521
- if (alias) return alias;
2522
- const fd = meta.descriptorByPath.get(key);
2523
- return fd ? valueTypeOf(meta, fd) : void 0;
2524
- });
2525
- }
2526
- //#endregion
2527
2719
  //#region src/query/query-guards.ts
2528
2720
  /**
2529
2721
  * Engine-agnostic query-time guards, applied in the core layer BEFORE filter
@@ -3595,6 +3787,17 @@ var AtscriptDbReadable = class {
3595
3787
  if (field === void 0) return void 0;
3596
3788
  return this._meta.columnMap.get(field) ?? field;
3597
3789
  }
3790
+ /**
3791
+ * Logical paths of the `@db.column.version.exempt` fields, including objects
3792
+ * whose direct children are all exempt. A patch that writes only these (and
3793
+ * no `$cas`) does not bump the version column. Empty when the table has no
3794
+ * `@db.column.version`.
3795
+ * @since 0.1.150
3796
+ */
3797
+ get versionExemptFields() {
3798
+ this._ensureBuilt();
3799
+ return this._meta.versionExemptPaths;
3800
+ }
3598
3801
  /** Dimension fields from `@db.column.dimension`. */
3599
3802
  get dimensions() {
3600
3803
  this._ensureBuilt();
@@ -4661,6 +4864,16 @@ var BaseDbAdapter = class {
4661
4864
  this._verbose = enabled;
4662
4865
  }
4663
4866
  /**
4867
+ * Physical version column an UPDATE must bump, or `undefined` when it must
4868
+ * not: no `@db.column.version`, or a version-exempt patch
4869
+ * (`opts.keepVersion`, since 0.1.150 — the core never combines it with
4870
+ * `expectedVersion`; a violation throws).
4871
+ */
4872
+ _versionColumnFor(opts, expectedVersion) {
4873
+ if (!opts?.keepVersion) return this._table.versionColumnPhysical;
4874
+ if (expectedVersion !== void 0) throw new Error("keepVersion cannot combine with expectedVersion");
4875
+ }
4876
+ /**
4664
4877
  * Logs a debug message if verbose mode is enabled.
4665
4878
  * Adapters call this to log DB operations with zero overhead when disabled.
4666
4879
  */
@@ -5000,9 +5213,10 @@ var BaseDbAdapter = class {
5000
5213
  *
5001
5214
  * @param filter - Filter identifying the record to patch.
5002
5215
  * @param patch - The patch payload with array operations.
5216
+ * @param opts - `keepVersion`: skip the version bump (see {@link TDbUpdateOptions}).
5003
5217
  * @returns Update result.
5004
5218
  */
5005
- async nativePatch(_filter, _patch, _ops, _expectedVersion) {
5219
+ async nativePatch(_filter, _patch, _ops, _expectedVersion, _opts) {
5006
5220
  throw new Error("Native patch not supported by this adapter");
5007
5221
  }
5008
5222
  /**
@@ -5047,6 +5261,7 @@ var BaseDbAdapter = class {
5047
5261
  continue;
5048
5262
  }
5049
5263
  if (opts.shouldSkipType?.(index.type)) continue;
5264
+ if (index.type === "fulltext" && !index.fields.some((f) => !f.integer)) continue;
5050
5265
  desiredNames.add(index.key);
5051
5266
  if (!existingNames.has(index.key)) {
5052
5267
  await attempt(`create index "${index.key}"`, () => opts.createIndex(index));
@@ -5067,6 +5282,13 @@ var BaseDbAdapter = class {
5067
5282
  /**
5068
5283
  * Returns available search indexes for this adapter.
5069
5284
  * UI uses this to show index picker. Override in adapters that support search.
5285
+ *
5286
+ * A fulltext index may carry integer members ({@link TDbIndexField.integer},
5287
+ * since 0.1.150): list them in `fields`, keep them out of the physical text
5288
+ * index, and OR `member = n` into the search predicate when
5289
+ * `searchTermInteger(text)` is defined. An index of integer members only
5290
+ * is still listed (it makes the table natively searchable); a term that is
5291
+ * not a whole number then matches nothing.
5070
5292
  */
5071
5293
  getSearchIndexes() {
5072
5294
  return [];
@@ -5112,7 +5334,9 @@ var BaseDbAdapter = class {
5112
5334
  return false;
5113
5335
  }
5114
5336
  /**
5115
- * Full-text search. Override in adapters that support search.
5337
+ * Full-text search. Override in adapters that support search. The text
5338
+ * match is OR'd with exact-number equality on the index's integer members
5339
+ * when the whole term is a whole number (see `searchTermInteger`).
5116
5340
  *
5117
5341
  * @param text - Search text.
5118
5342
  * @param query - Filter, sort, limit, etc.
@@ -5696,6 +5920,8 @@ function matchByKeys(a, b, keys) {
5696
5920
  }
5697
5921
  //#endregion
5698
5922
  //#region src/patch/patch-decomposer.ts
5923
+ /** Metadata tag the flattener puts on top-level array fields (shared with `isVersionExemptPatch`). */
5924
+ const TOP_LEVEL_ARRAY_TAG = "db.__topLevelArray";
5699
5925
  /**
5700
5926
  * Decomposes a patch payload into a flat update object for adapters
5701
5927
  * that don't support native patch operations.
@@ -5714,7 +5940,7 @@ function matchByKeys(a, b, keys) {
5714
5940
  */
5715
5941
  function decomposePatch(payload, table) {
5716
5942
  const update = {};
5717
- flattenPatchPayload(payload, "", update, table, "db.__topLevelArray");
5943
+ flattenPatchPayload(payload, "", update, table, TOP_LEVEL_ARRAY_TAG);
5718
5944
  return update;
5719
5945
  }
5720
5946
  function flattenPatchPayload(payload, prefix, update, table, topLevelArrayTag) {
@@ -5796,6 +6022,44 @@ function assertNoVersionWrites(data, versionColumn) {
5796
6022
  }]);
5797
6023
  }
5798
6024
  //#endregion
6025
+ //#region src/patch/version-exempt.ts
6026
+ /**
6027
+ * Whether a logical (pre-decomposition) patch writes ONLY version-exempt
6028
+ * fields (`@db.column.version.exempt`). `data` has nav fields, filter keys and
6029
+ * derived fields removed and `$cas` separated. Mirrors `flattenPatchPayload`
6030
+ * (keep the two side by side): PK keys are skipped; an exempt path (or one
6031
+ * under an exempt ancestor) is covered whatever its value (scalar, field op,
6032
+ * array ops, object); a non-exempt merge-strategy object recurses (only the
6033
+ * supplied children are written); anything else is a non-exempt write.
6034
+ * True iff at least one key is covered and none is not.
6035
+ * @since 0.1.150
6036
+ */
6037
+ function isVersionExemptPatch(data, table) {
6038
+ const meta = table.getMetadata();
6039
+ if (meta.versionExemptPaths.size === 0) return false;
6040
+ const walk = (obj, prefix) => {
6041
+ let result = "none";
6042
+ for (const [k, value] of Object.entries(obj)) {
6043
+ const key = prefix ? `${prefix}.${k}` : k;
6044
+ if (table.primaryKeys.includes(key)) continue;
6045
+ if (meta.isVersionExemptPath(key)) {
6046
+ result = "exempt";
6047
+ continue;
6048
+ }
6049
+ const flatType = table.flatMap.get(key);
6050
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && !isDbFieldOp(value) && !flatType?.metadata?.get("db.__topLevelArray") && !flatType?.metadata?.has("db.json") && flatType?.metadata?.get("db.patch.strategy") === "merge") {
6051
+ const inner = walk(value, key);
6052
+ if (inner === "versioned") return "versioned";
6053
+ if (inner === "exempt") result = "exempt";
6054
+ continue;
6055
+ }
6056
+ return "versioned";
6057
+ }
6058
+ return result;
6059
+ };
6060
+ return walk(data, "") === "exempt";
6061
+ }
6062
+ //#endregion
5799
6063
  //#region src/table/db-table.ts
5800
6064
  /** Returns true when `value` is a plain object carrying any `$`-prefixed key (an operator object). */
5801
6065
  function _hasOperatorKeys(value) {
@@ -6003,6 +6267,8 @@ function concurrentChange() {
6003
6267
  message: "The record changed during the write — nothing was written, retry"
6004
6268
  }]);
6005
6269
  }
6270
+ /** Hand-off for a version-exempt patch (since 0.1.150) — shared, never mutated. */
6271
+ const KEEP_VERSION = Object.freeze({ keepVersion: true });
6006
6272
  /** Upper bound of keys per `touchMany` UPDATE statement (parameter-count safety). */
6007
6273
  const TOUCH_MANY_CHUNK = 500;
6008
6274
  /** `touchMany` input rejection — always `INVALID_QUERY`, path names the key. */
@@ -6291,12 +6557,13 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6291
6557
  if (!matched[i] && _ownedBy?.strict?.[i]) throw concurrentChange();
6292
6558
  continue;
6293
6559
  }
6560
+ const updateOpts = expectedVersion === void 0 && versionColumn !== void 0 && isVersionExemptPatch(data, this) ? [KEEP_VERSION] : [];
6294
6561
  let result;
6295
6562
  if (this.adapter.supportsNativePatch()) {
6296
6563
  const ops = separateFieldOps(data);
6297
6564
  const translatedOps = ops ? this._fieldMapper.translateOpsKeys(ops, this._meta) : void 0;
6298
6565
  const translatedData = this._fieldMapper.translatePatchKeys(data, this._meta);
6299
- result = await this.adapter.nativePatch(translatedFilter, translatedData, translatedOps, expectedVersion);
6566
+ result = await this.adapter.nativePatch(translatedFilter, translatedData, translatedOps, expectedVersion, ...updateOpts);
6300
6567
  } else {
6301
6568
  const update = decomposePatch(data, this);
6302
6569
  const ops = separateFieldOps(update);
@@ -6307,8 +6574,8 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6307
6574
  filter: translatedFilter,
6308
6575
  controls: {}
6309
6576
  }), this);
6310
- result = await this.adapter.updateOne(translatedFilter, resolved, translatedOps, expectedVersion);
6311
- } else result = await this.adapter.updateOne(translatedFilter, translatedUpdate, translatedOps, expectedVersion);
6577
+ result = await this.adapter.updateOne(translatedFilter, resolved, translatedOps, expectedVersion, ...updateOpts);
6578
+ } else result = await this.adapter.updateOne(translatedFilter, translatedUpdate, translatedOps, expectedVersion, ...updateOpts);
6312
6579
  }
6313
6580
  modifiedCount += result.modifiedCount;
6314
6581
  matched[i] = result.matchedCount > 0;
@@ -6455,6 +6722,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6455
6722
  message: "$cas is not supported on updateMany — use bulkUpdate with per-row $cas for version-locked batch updates"
6456
6723
  }]);
6457
6724
  if (versionColumn !== void 0) assertNoVersionWrites(dataCopy, versionColumn);
6725
+ const updateOpts = versionColumn !== void 0 && isVersionExemptPatch(dataCopy, this) ? [KEEP_VERSION] : [];
6458
6726
  await this._encryptItems([dataCopy], "patch");
6459
6727
  const update = decomposePatch(dataCopy, this);
6460
6728
  const ops = separateFieldOps(update);
@@ -6468,7 +6736,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
6468
6736
  }),
6469
6737
  modifiedCount: 0
6470
6738
  };
6471
- return enrichFkViolation(this._meta, () => this.adapter.updateMany(translatedFilter, translatedUpdate, translatedOps));
6739
+ return enrichFkViolation(this._meta, () => this.adapter.updateMany(translatedFilter, translatedUpdate, translatedOps, ...updateOpts));
6472
6740
  }
6473
6741
  async replaceMany(filter, data) {
6474
6742
  this._ensureBuilt();
@@ -7540,7 +7808,8 @@ function computeTableSnapshot(readable, typeMapper, tableOptions) {
7540
7808
  type: idx.type,
7541
7809
  fields: idx.fields.map((f) => ({
7542
7810
  name: f.name,
7543
- sort: f.sort
7811
+ sort: f.sort,
7812
+ ...f.integer ? { integer: true } : {}
7544
7813
  }))
7545
7814
  })).toSorted((a, b) => a.key.localeCompare(b.key));
7546
7815
  const foreignKeys = [...readable.foreignKeys.values()].map((fk) => ({
@@ -7847,4 +8116,4 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
7847
8116
  return diff;
7848
8117
  }
7849
8118
  //#endregion
7850
- export { FieldMappingStrategy as $, geoIndexNotFoundMessage as A, classifyQueryPath as B, ALL_VIEW_CAPABILITIES as C, NativeIntegrity as D, IntegrityStrategy as E, acceptedOperatorsHint as F, guardPath as G, groupSourceVerdict as H, assertGeoPoint as I, narrowerFilterOps as J, guardPaths as K, bucketSourceVerdict as L, vectorIndexNotFoundMessage as M, ADAPTER_FILTER_REASON as N, AtscriptDbReadable as O, ENCRYPTED_REASON as P, DocumentFieldMapper as Q, canFilterLeaf as R, ALL_BUCKET_UNITS as S, createFailureCollector as T, guardAggregate as U, collectQueryPaths as V, guardFilter as W, unsupportedOperatorMessage as X, sortFieldNames as Y, RelationalFieldMapper as Z, isViewType as _, computeTableHash as a, aliasTargetOf as at, decomposePatch as b, snapshotToExistingColumns as c, jsonValueAncestor as ct, computeForeignKeyDiff as d, NoopLogger as dt, UniquSelect as et, fkColumns as f, isAtscriptDbView as g, AtscriptDbView as h, computeSchemaHash as i, isGeoPointType as it, searchIndexNotFoundMessage as j, resolveDesignType as k, snapshotToExistingTableOptions as l, normalizeComputedSelect as lt, hasForeignKeyChanges as m, isColumnTypeChanged as n, TableMetadata as nt, computeTableSnapshot as o, isBucketableField as ot, fkKey as p, guardQuery as q, canonicalizeQueryNode as r, isGeoIndexableType as rt, computeViewSnapshot as s, isJsonValueField as st, computeColumnDiff as t, numericOperandProblem as tt, viewSnapshotSources as u, resolveCalendarBuckets as ut, AtscriptDbTable as v, BaseDbAdapter as w, ApplicationIntegrity as x, assertNoVersionWrites as y, checkHavingKeys as z };
8119
+ 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 };