@atscript/db 0.1.161 → 0.1.163
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.
- package/dist/agg.cjs +1 -1
- package/dist/agg.d.cts +1 -1
- package/dist/agg.d.mts +1 -1
- package/dist/agg.mjs +1 -1
- package/dist/{aggregate-fns-B_sgl4mB.mjs → aggregate-fns-DsS9UQVB.mjs} +1 -1
- package/dist/{aggregate-fns-DFtKAv7Q.cjs → aggregate-fns-DupYzFD7.cjs} +1 -1
- package/dist/{buckets-B4T_alUe.d.cts → buckets-B8UOod-v.d.cts} +488 -57
- package/dist/{buckets-8mG67Mtw.d.mts → buckets-CHyspjYP.d.mts} +488 -57
- package/dist/{column-diff-DpeUbhEI.cjs → column-diff-BIGX3sRH.cjs} +853 -232
- package/dist/{column-diff-DR1glykD.mjs → column-diff-BfNIlMwX.mjs} +836 -233
- package/dist/{validator-B3xlbrWf.d.cts → db-error-B_DRL1E9.d.cts} +18 -89
- package/dist/{validator-B3xlbrWf.d.mts → db-error-B_DRL1E9.d.mts} +18 -89
- package/dist/{db-error-JcfJqqpk.cjs → db-error-DmF1_xrz.cjs} +13 -0
- package/dist/{db-error-eSsQX7rt.mjs → db-error-hraC12B9.mjs} +8 -1
- package/dist/{db-validator-plugin-BWy60OvG.d.cts → db-validator-plugin-wRqMYNBI.d.cts} +6 -0
- package/dist/{db-validator-plugin-BWy60OvG.d.mts → db-validator-plugin-wRqMYNBI.d.mts} +6 -0
- package/dist/{fk-diff-5gX42hBt.d.mts → fk-diff-BNGawZi9.d.cts} +34 -10
- package/dist/{fk-diff-BWdQ7kRZ.d.cts → fk-diff-rj9N2QLw.d.mts} +34 -10
- package/dist/index.cjs +15 -11
- package/dist/index.d.cts +24 -21
- package/dist/index.d.mts +24 -21
- package/dist/index.mjs +10 -12
- package/dist/{nested-writer-BOj_c_lI.mjs → nested-writer-ClUX0DB0.mjs} +16 -22
- package/dist/{nested-writer-Bl1SZS7x.cjs → nested-writer-yCy6wGuN.cjs} +16 -22
- package/dist/{object-CtPYTIvy.d.cts → object-Czia_qD4.d.cts} +7 -1
- package/dist/{object-CtPYTIvy.d.mts → object-Czia_qD4.d.mts} +7 -1
- package/dist/{object-Djg28csK.cjs → object-GjsuPNqo.cjs} +19 -0
- package/dist/{object-TkiJQ-Dp.mjs → object-PQ1J8jri.mjs} +14 -1
- package/dist/ops.cjs +6 -21
- package/dist/ops.mjs +6 -21
- package/dist/plugin.cjs +263 -323
- package/dist/plugin.mjs +163 -223
- package/dist/rel.cjs +2 -2
- package/dist/rel.d.cts +3 -3
- package/dist/rel.d.mts +3 -3
- package/dist/rel.mjs +2 -2
- package/dist/{relation-helpers-17_2f5hX.d.mts → relation-helpers-DloMywNs.d.cts} +1 -1
- package/dist/{relation-helpers-BLBrLQYi.d.cts → relation-helpers-zwt19E7r.d.mts} +1 -1
- package/dist/{relation-loader-DeulLrAw.cjs → relation-loader-DujL_Mh9.cjs} +1 -1
- package/dist/{relation-loader-CNBMPfRr.mjs → relation-loader-Qv3LrQ42.mjs} +1 -1
- package/dist/{search-fields-CT_UZ9fg.cjs → search-fields-B49-RCWd.cjs} +65 -1
- package/dist/{search-fields-CE8cmj9X.mjs → search-fields-BPNsxrCk.mjs} +48 -2
- package/dist/search-fields-CHqqcC3W.d.cts +30 -0
- package/dist/search-fields-CHqqcC3W.d.mts +30 -0
- package/dist/server-stamped-B6XbUQ4p.cjs +33 -0
- package/dist/server-stamped-BoP56K9M.mjs +22 -0
- package/dist/shared.cjs +42 -26
- package/dist/shared.d.cts +98 -4
- package/dist/shared.d.mts +98 -4
- package/dist/shared.mjs +4 -2
- package/dist/sync.cjs +228 -65
- package/dist/sync.d.cts +41 -5
- package/dist/sync.d.mts +41 -5
- package/dist/sync.mjs +228 -66
- package/dist/{validator-B4YDLUkj.cjs → validator-DK9Ijpo7.cjs} +3 -2
- package/dist/validator-DX97RzC2.d.cts +108 -0
- package/dist/validator-DX97RzC2.d.mts +108 -0
- package/dist/{validator-B3fPJjS1.mjs → validator-DuDWsIdQ.mjs} +3 -2
- package/dist/validator.cjs +6 -3
- package/dist/validator.d.cts +5 -4
- package/dist/validator.d.mts +5 -4
- package/dist/validator.mjs +5 -4
- package/dist/{validation-utils-BGjqc2jU.mjs → view-validation-BcgDq7M9.mjs} +364 -20
- package/dist/{validation-utils-CtLNlBtB.cjs → view-validation-BiDLCYMw.cjs} +452 -18
- package/package.json +6 -6
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { a as aggregateExpressionsNotSupported,
|
|
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-
|
|
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-
|
|
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-
|
|
5
|
-
import { a as DERIVED_INCOMPATIBLE, n as
|
|
6
|
-
import { a as isPlainObject, i as isEmptyObject, n as findAncestorInSet, o as selfOrAncestor, r as getPath, t as deletePath } from "./object-
|
|
1
|
+
import { a as aggregateExpressionsNotSupported, c as invalidQuery, d as spaceClosedError, n as CasMismatchError, r as DbError } from "./db-error-hraC12B9.mjs";
|
|
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-DsS9UQVB.mjs";
|
|
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-ClUX0DB0.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-DuDWsIdQ.mjs";
|
|
5
|
+
import { a as searchMemberKind, c as DERIVED_INCOMPATIBLE, n as ENCRYPTED_SEARCH_FEATURES, o as numericTypeProblem, t as ENCRYPTED_INCOMPATIBLE, u as isJsonLeafType } from "./search-fields-BPNsxrCk.mjs";
|
|
6
|
+
import { a as isPlainObject, i as isEmptyObject, n as findAncestorInSet, o as selfOrAncestor, r as getPath, s as stableJson, t as deletePath } from "./object-PQ1J8jri.mjs";
|
|
7
7
|
import { resolveAlias as resolveAlias$1 } from "./agg.mjs";
|
|
8
8
|
import { t as NOT_DIMENSION_REASON } from "./aggregate-rules-jdPrxqWa.mjs";
|
|
9
9
|
import { isDbFieldOp, separateCas, separateFieldOps } from "./ops.mjs";
|
|
@@ -319,7 +319,8 @@ function resolveViewSource(sourceType, logicalPath, nestedObjects) {
|
|
|
319
319
|
return jsonRoot === logicalPath ? {
|
|
320
320
|
column,
|
|
321
321
|
designType,
|
|
322
|
-
optional
|
|
322
|
+
optional,
|
|
323
|
+
jsonColumn: true
|
|
323
324
|
} : {
|
|
324
325
|
column,
|
|
325
326
|
jsonPath: logicalPath.slice(jsonRoot.length + 1).split("."),
|
|
@@ -509,6 +510,12 @@ var TableMetadata = class {
|
|
|
509
510
|
* @since 0.1.156
|
|
510
511
|
*/
|
|
511
512
|
onUpdateNow = /* @__PURE__ */ new Set();
|
|
513
|
+
/**
|
|
514
|
+
* Top-level fields annotated with `@db.onUpdate.inc`: `0` on insert, `+1` in
|
|
515
|
+
* the same statement on every update and replace.
|
|
516
|
+
* @since 0.1.162
|
|
517
|
+
*/
|
|
518
|
+
onUpdateInc = /* @__PURE__ */ new Set();
|
|
512
519
|
/** Logical path → `@db.column` override (top-level keys only on document storage). */
|
|
513
520
|
columnMap = /* @__PURE__ */ new Map();
|
|
514
521
|
dimensions = [];
|
|
@@ -739,6 +746,13 @@ var TableMetadata = class {
|
|
|
739
746
|
adapter.onFieldScanned?.(entry.path, entry.type, entry.metadata);
|
|
740
747
|
}
|
|
741
748
|
for (const path of this.encryptedFields) if (findAncestorInSet(path, this.encryptedFields) !== void 0) this.encryptedFields.delete(path);
|
|
749
|
+
if (this.encryptedFields.size > 0) for (const entry of collected) {
|
|
750
|
+
if (entry.member || this.ignoredFields.has(entry.path)) continue;
|
|
751
|
+
const feature = ENCRYPTED_SEARCH_FEATURES.find((key) => entry.metadata.has(key));
|
|
752
|
+
if (!feature) continue;
|
|
753
|
+
const encrypted = findAncestorInSet(entry.path, this.encryptedFields);
|
|
754
|
+
if (encrypted !== void 0) throw new Error(`@${feature} on "${entry.path}" — it is inside the @db.encrypted field "${encrypted}"; search over ciphertext is impossible`);
|
|
755
|
+
}
|
|
742
756
|
if (!this.nestedObjects) this._classifyFields();
|
|
743
757
|
const overrides = adapter.getMetadataOverrides?.(this);
|
|
744
758
|
if (overrides) this._applyOverrides(overrides);
|
|
@@ -834,6 +848,14 @@ var TableMetadata = class {
|
|
|
834
848
|
const resolvedDefault = resolveDefaultFromMetadata(metadata);
|
|
835
849
|
if (resolvedDefault) this.defaults.set(fieldName, resolvedDefault);
|
|
836
850
|
if (metadata.has("db.onUpdate.now")) this.onUpdateNow.add(fieldName);
|
|
851
|
+
if (metadata.has("db.onUpdate.inc")) {
|
|
852
|
+
if (fieldName.includes(".")) throw new Error(`@db.onUpdate.inc on "${fieldName}" must sit on a top-level field of the table`);
|
|
853
|
+
this.onUpdateInc.add(fieldName);
|
|
854
|
+
if (!this.defaults.has(fieldName)) this.defaults.set(fieldName, {
|
|
855
|
+
kind: "value",
|
|
856
|
+
value: "0"
|
|
857
|
+
});
|
|
858
|
+
}
|
|
837
859
|
if (metadata.has("db.ignore")) this.ignoredFields.add(fieldName);
|
|
838
860
|
if (isNavRelation(metadata)) {
|
|
839
861
|
this.navFields.add(fieldName);
|
|
@@ -997,14 +1019,7 @@ var TableMetadata = class {
|
|
|
997
1019
|
const reject = (what, why) => {
|
|
998
1020
|
throw new Error(`@db.encrypted on "${fieldName}" cannot coexist with ${what} — ${why}`);
|
|
999
1021
|
};
|
|
1000
|
-
if (metadata.has(
|
|
1001
|
-
if (metadata.has("db.rel.FK")) reject("@db.rel.FK", "joins are impossible over ciphertext");
|
|
1002
|
-
if (metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext") || metadata.has("db.index.geo")) reject("@db.index.*", "indexes over ciphertext are meaningless");
|
|
1003
|
-
if (metadata.has("db.search.vector") || metadata.has("db.search.filter")) reject("@db.search.*", "search over ciphertext is impossible");
|
|
1004
|
-
if (metadata.has("db.mongo.search.text") || metadata.has("db.mongo.search.autocomplete")) reject("@db.mongo.search.*", "Atlas Search over ciphertext is impossible");
|
|
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");
|
|
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");
|
|
1022
|
+
for (const [name, why] of ENCRYPTED_INCOMPATIBLE) if (metadata.has(name)) reject(`@${name}`, why);
|
|
1008
1023
|
if (metadata.get("db.patch.strategy") === "merge") reject("@db.patch.strategy \"merge\"", "ciphertext is opaque — partial merges would silently drop omitted keys");
|
|
1009
1024
|
}
|
|
1010
1025
|
/**
|
|
@@ -4064,6 +4079,19 @@ function resolveDefaultFromMetadata(metadata) {
|
|
|
4064
4079
|
fn: "now"
|
|
4065
4080
|
};
|
|
4066
4081
|
}
|
|
4082
|
+
const LOCK_WAITS = new Set([
|
|
4083
|
+
"block",
|
|
4084
|
+
"nowait",
|
|
4085
|
+
"skipLocked"
|
|
4086
|
+
]);
|
|
4087
|
+
/** A plain row read — not grouped, aggregated or `$count`. */
|
|
4088
|
+
function isRowRead(controls) {
|
|
4089
|
+
if (!controls) return true;
|
|
4090
|
+
if (controls.$count) return false;
|
|
4091
|
+
if (Array.isArray(controls.$groupBy) && controls.$groupBy.length > 0) return false;
|
|
4092
|
+
const select = controls.$select;
|
|
4093
|
+
return !(Array.isArray(select) && select.some((item) => isAggregateExpr(item) || isAggregateOfExpr(item)));
|
|
4094
|
+
}
|
|
4067
4095
|
/**
|
|
4068
4096
|
* `query` with `$groupBy` defaulted to `[]` — an aggregate without one is the
|
|
4069
4097
|
* ungrouped query (one group), as `$groupBy: []` documents it.
|
|
@@ -4072,10 +4100,7 @@ function resolveDefaultFromMetadata(metadata) {
|
|
|
4072
4100
|
function withGroupBy(query) {
|
|
4073
4101
|
const controls = query.controls ?? {};
|
|
4074
4102
|
if (Array.isArray(controls.$groupBy)) return query;
|
|
4075
|
-
if (controls.$groupBy !== void 0 && controls.$groupBy !== null) throw
|
|
4076
|
-
path: "$groupBy",
|
|
4077
|
-
message: "$groupBy must be an array of field names"
|
|
4078
|
-
}]);
|
|
4103
|
+
if (controls.$groupBy !== void 0 && controls.$groupBy !== null) throw invalidQuery("$groupBy", "$groupBy must be an array of field names");
|
|
4079
4104
|
return {
|
|
4080
4105
|
...query,
|
|
4081
4106
|
controls: {
|
|
@@ -4183,10 +4208,7 @@ var AtscriptDbReadable = class {
|
|
|
4183
4208
|
_ensureSearchable() {
|
|
4184
4209
|
if (this.adapter.isSearchable()) return;
|
|
4185
4210
|
const hasVectorIndex = this.adapter.getSearchIndexes().some((i) => i.type === "vector");
|
|
4186
|
-
throw
|
|
4187
|
-
path: "$search",
|
|
4188
|
-
message: `Table "${this.tableName}" has no text search index` + (hasVectorIndex ? " — a @db.search.vector index only answers vectorSearch()" : "")
|
|
4189
|
-
}]);
|
|
4211
|
+
throw invalidQuery("$search", `Table "${this.tableName}" has no text search index` + (hasVectorIndex ? " — a @db.search.vector index only answers vectorSearch()" : ""));
|
|
4190
4212
|
}
|
|
4191
4213
|
/** Engine-agnostic query-time guards (encrypted-field refs, $geoWithin shape). */
|
|
4192
4214
|
_guardQuery(query) {
|
|
@@ -4415,6 +4437,17 @@ var AtscriptDbReadable = class {
|
|
|
4415
4437
|
return this._meta.columnMap.get(field) ?? field;
|
|
4416
4438
|
}
|
|
4417
4439
|
/**
|
|
4440
|
+
* Physical column names of the `@db.onUpdate.inc` fields (since 0.1.162):
|
|
4441
|
+
* adapters add `col = col + 1` to every replace. Empty when there are none.
|
|
4442
|
+
*
|
|
4443
|
+
* @internal Adapter-facing surface; not part of the consumer API.
|
|
4444
|
+
*/
|
|
4445
|
+
get onUpdateIncPhysical() {
|
|
4446
|
+
this._ensureBuilt();
|
|
4447
|
+
return this._onUpdateIncPhysical ??= [...this._meta.onUpdateInc].map((field) => this._meta.columnMap.get(field) ?? field);
|
|
4448
|
+
}
|
|
4449
|
+
_onUpdateIncPhysical;
|
|
4450
|
+
/**
|
|
4418
4451
|
* Logical paths of the `@db.column.version.exempt` fields, including objects
|
|
4419
4452
|
* whose direct children are all exempt. A patch that writes only these (and
|
|
4420
4453
|
* no `$cas`) does not bump the version column. Empty when the table has no
|
|
@@ -4663,10 +4696,7 @@ var AtscriptDbReadable = class {
|
|
|
4663
4696
|
const sort = controls?.$sort;
|
|
4664
4697
|
if (!sort || typeof sort !== "object") return void 0;
|
|
4665
4698
|
const keys = Object.keys(sort);
|
|
4666
|
-
if (keys.length === 0 || controls
|
|
4667
|
-
if (Array.isArray(controls.$groupBy) && controls.$groupBy.length > 0) return void 0;
|
|
4668
|
-
const select = controls.$select;
|
|
4669
|
-
if (Array.isArray(select) && select.some((item) => isAggregateExpr(item) || isAggregateOfExpr(item))) return;
|
|
4699
|
+
if (keys.length === 0 || !isRowRead(controls)) return void 0;
|
|
4670
4700
|
const pk = this._meta.primaryKeys;
|
|
4671
4701
|
if (pk.length === 0) return void 0;
|
|
4672
4702
|
const sorted = new Set(keys.map((key) => this._meta.physicalPath(key)));
|
|
@@ -4820,15 +4850,62 @@ var AtscriptDbReadable = class {
|
|
|
4820
4850
|
return this._type.validator(opts);
|
|
4821
4851
|
}
|
|
4822
4852
|
/**
|
|
4853
|
+
* The row lock a `findOne` / `findMany` asked for (`opts.lock`, since
|
|
4854
|
+
* 0.1.162), validated — `undefined` when none was asked for.
|
|
4855
|
+
*
|
|
4856
|
+
* @throws DbError `INVALID_QUERY` — bad `lock` / `wait` value, a view, a
|
|
4857
|
+
* grouped / aggregate / `$count` read, or no `withTransaction` scope of
|
|
4858
|
+
* this table's adapter; `LOCK_NOT_SUPPORTED` — the adapter cannot lock.
|
|
4859
|
+
*/
|
|
4860
|
+
_readLock(query, opts) {
|
|
4861
|
+
const mode = opts?.lock;
|
|
4862
|
+
const wait = opts?.wait;
|
|
4863
|
+
if (mode === void 0) {
|
|
4864
|
+
if (wait !== void 0) throw invalidQuery("wait", "`wait` requires `lock`");
|
|
4865
|
+
return;
|
|
4866
|
+
}
|
|
4867
|
+
if (mode !== "update" && mode !== "share") throw invalidQuery("lock", `Unknown lock mode "${String(mode)}" — use 'update' or 'share'`);
|
|
4868
|
+
if (wait !== void 0 && !LOCK_WAITS.has(wait)) throw invalidQuery("wait", `Unknown lock wait "${String(wait)}" — use 'block', 'nowait' or 'skipLocked'`);
|
|
4869
|
+
if (this.isView) throw invalidQuery("lock", `Locking reads run on tables only — "${this.tableName}" is a view`);
|
|
4870
|
+
if (!isRowRead(query.controls)) throw invalidQuery("lock", "A locking read cannot group, aggregate or $count rows");
|
|
4871
|
+
if (!this.adapter.supportsReadLocks()) throw new DbError("LOCK_NOT_SUPPORTED", [{
|
|
4872
|
+
path: "lock",
|
|
4873
|
+
message: `The adapter behind "${this.tableName}" cannot lock rows on read — use $cas / withOptimisticRetry`
|
|
4874
|
+
}]);
|
|
4875
|
+
if (!this.adapter.inTransactionScope()) throw invalidQuery("lock", "A locking read requires a transaction — run it inside withTransaction() of this table's adapter");
|
|
4876
|
+
return {
|
|
4877
|
+
mode,
|
|
4878
|
+
wait: wait ?? "block"
|
|
4879
|
+
};
|
|
4880
|
+
}
|
|
4881
|
+
/**
|
|
4823
4882
|
* Finds a single record matching the query.
|
|
4824
4883
|
* The return type automatically excludes nav props unless they are
|
|
4825
4884
|
* explicitly requested via `$with`.
|
|
4885
|
+
*
|
|
4886
|
+
* `opts.lock` (since 0.1.162) makes it a locking read — see
|
|
4887
|
+
* {@link TReadLockOptions}:
|
|
4888
|
+
*
|
|
4889
|
+
* ```typescript
|
|
4890
|
+
* await adapter.withTransaction(async () => {
|
|
4891
|
+
* const order = await orders.findOne({ filter: { id }, controls: {} }, { lock: 'update' })
|
|
4892
|
+
* // … the row stays locked until the transaction ends
|
|
4893
|
+
* })
|
|
4894
|
+
* ```
|
|
4826
4895
|
*/
|
|
4827
|
-
async findOne(query) {
|
|
4896
|
+
async findOne(query, opts) {
|
|
4828
4897
|
this._ensureBuilt();
|
|
4829
4898
|
this._guardQuery(query);
|
|
4899
|
+
const lock = this._readLock(query, opts);
|
|
4900
|
+
return this._findOne(query, lock);
|
|
4901
|
+
}
|
|
4902
|
+
/** {@link findOne} of a guarded query with an already validated lock. */
|
|
4903
|
+
async _findOne(query, lock) {
|
|
4830
4904
|
const read = this._translateRead(query);
|
|
4831
|
-
const result = await this.adapter.findOne(
|
|
4905
|
+
const result = await this.adapter.findOne(lock ? {
|
|
4906
|
+
...read.translated,
|
|
4907
|
+
lock
|
|
4908
|
+
} : read.translated);
|
|
4832
4909
|
if (!result) return null;
|
|
4833
4910
|
const [row] = await this._finishRead([result], read);
|
|
4834
4911
|
return row;
|
|
@@ -4837,12 +4914,20 @@ var AtscriptDbReadable = class {
|
|
|
4837
4914
|
* Finds all records matching the query.
|
|
4838
4915
|
* The return type automatically excludes nav props unless they are
|
|
4839
4916
|
* explicitly requested via `$with`.
|
|
4917
|
+
*
|
|
4918
|
+
* `opts.lock` (since 0.1.162) makes it a locking read — see
|
|
4919
|
+
* {@link TReadLockOptions}. Sort by the primary key when locking several
|
|
4920
|
+
* rows, so concurrent transactions take the locks in the same order.
|
|
4840
4921
|
*/
|
|
4841
|
-
async findMany(query) {
|
|
4922
|
+
async findMany(query, opts) {
|
|
4842
4923
|
this._ensureBuilt();
|
|
4843
4924
|
this._guardQuery(query);
|
|
4925
|
+
const lock = this._readLock(query, opts);
|
|
4844
4926
|
const read = this._translateRead(query);
|
|
4845
|
-
return await this._finishRead(await this.adapter.findMany(
|
|
4927
|
+
return await this._finishRead(await this.adapter.findMany(lock ? {
|
|
4928
|
+
...read.translated,
|
|
4929
|
+
lock
|
|
4930
|
+
} : read.translated), read);
|
|
4846
4931
|
}
|
|
4847
4932
|
/**
|
|
4848
4933
|
* `findMany` for the generic `$with` loader. With `partitionBy` (logical
|
|
@@ -4912,10 +4997,7 @@ var AtscriptDbReadable = class {
|
|
|
4912
4997
|
const { buckets } = computed;
|
|
4913
4998
|
if ($select) {
|
|
4914
4999
|
const groupBySet = new Set($groupBy);
|
|
4915
|
-
for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw
|
|
4916
|
-
path: "$select",
|
|
4917
|
-
message: `Plain field "${item}" in $select must also appear in $groupBy`
|
|
4918
|
-
}]);
|
|
5000
|
+
for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw invalidQuery("$select", `Plain field "${item}" in $select must also appear in $groupBy`);
|
|
4919
5001
|
}
|
|
4920
5002
|
const { dimensions, measures } = this._meta;
|
|
4921
5003
|
if (isStrictTable(this._meta)) {
|
|
@@ -4927,24 +5009,15 @@ var AtscriptDbReadable = class {
|
|
|
4927
5009
|
if (bucketAliases.has(field)) continue;
|
|
4928
5010
|
const fd = descriptors.get(field);
|
|
4929
5011
|
const verdict = fd && groupSourceVerdict(fd, this._meta, this.adapter);
|
|
4930
|
-
if (verdict ? !verdict.ok && verdict.code === "notDimension" : !dimSet.has(field)) throw
|
|
4931
|
-
path: "$groupBy",
|
|
4932
|
-
message: `Field "${field}" is not a dimension`
|
|
4933
|
-
}]);
|
|
5012
|
+
if (verdict ? !verdict.ok && verdict.code === "notDimension" : !dimSet.has(field)) throw invalidQuery("$groupBy", `Field "${field}" is not a dimension`);
|
|
4934
5013
|
}
|
|
4935
5014
|
if ($select) for (const item of $select) {
|
|
4936
5015
|
if (!isAggregateExpr(item) || item.$field === "*" || measSet.has(item.$field)) continue;
|
|
4937
5016
|
if (item.$fn === "countDistinct" || isFirstLast(item)) {
|
|
4938
5017
|
if (dimSet.has(item.$field)) continue;
|
|
4939
|
-
throw
|
|
4940
|
-
path: "$select",
|
|
4941
|
-
message: `Aggregate field "${item.$field}" is not a dimension or measure`
|
|
4942
|
-
}]);
|
|
5018
|
+
throw invalidQuery("$select", `Aggregate field "${item.$field}" is not a dimension or measure`);
|
|
4943
5019
|
}
|
|
4944
|
-
throw
|
|
4945
|
-
path: "$select",
|
|
4946
|
-
message: `Aggregate field "${item.$field}" is not a measure`
|
|
4947
|
-
}]);
|
|
5020
|
+
throw invalidQuery("$select", `Aggregate field "${item.$field}" is not a measure`);
|
|
4948
5021
|
}
|
|
4949
5022
|
}
|
|
4950
5023
|
const { quantityRefByField } = this._meta;
|
|
@@ -4953,10 +5026,7 @@ var AtscriptDbReadable = class {
|
|
|
4953
5026
|
for (const item of $select) {
|
|
4954
5027
|
if (!isAggregateExpr(item) || item.$field === "*") continue;
|
|
4955
5028
|
const refField = quantityRefByField.get(item.$field);
|
|
4956
|
-
if (refField && !groupBySet.has(refField)) throw
|
|
4957
|
-
path: "$select",
|
|
4958
|
-
message: `Aggregate "${item.$fn}(${item.$field})" requires "${refField}" in $groupBy — quantity-ref-tagged fields must be grouped by their dimension`
|
|
4959
|
-
}]);
|
|
5029
|
+
if (refField && !groupBySet.has(refField)) throw invalidQuery("$select", `Aggregate "${item.$fn}(${item.$field})" requires "${refField}" in $groupBy — quantity-ref-tagged fields must be grouped by their dimension`);
|
|
4960
5030
|
}
|
|
4961
5031
|
}
|
|
4962
5032
|
const searchTerm = query.controls.$search;
|
|
@@ -5186,16 +5256,10 @@ var AtscriptDbReadable = class {
|
|
|
5186
5256
|
}]);
|
|
5187
5257
|
assertGeoPoint(point, "$center");
|
|
5188
5258
|
const controls = query?.controls ?? {};
|
|
5189
|
-
if (controls.$sort) throw
|
|
5190
|
-
path: "$sort",
|
|
5191
|
-
message: "geoSearch results are distance-ordered — $sort is not allowed on this path"
|
|
5192
|
-
}]);
|
|
5259
|
+
if (controls.$sort) throw invalidQuery("$sort", "geoSearch results are distance-ordered — $sort is not allowed on this path");
|
|
5193
5260
|
for (const key of ["$maxDistance", "$minDistance"]) {
|
|
5194
5261
|
const v = controls[key];
|
|
5195
|
-
if (v !== void 0 && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) throw
|
|
5196
|
-
path: key,
|
|
5197
|
-
message: `${key} must be a non-negative number of meters`
|
|
5198
|
-
}]);
|
|
5262
|
+
if (v !== void 0 && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) throw invalidQuery(key, `${key} must be a non-negative number of meters`);
|
|
5199
5263
|
}
|
|
5200
5264
|
this._guardQuery(query);
|
|
5201
5265
|
return this._translateRead(query);
|
|
@@ -5215,10 +5279,17 @@ var AtscriptDbReadable = class {
|
|
|
5215
5279
|
*
|
|
5216
5280
|
* // With relations — only requested nav props appear
|
|
5217
5281
|
* const user = await table.findById('123', { controls: { $with: [{ name: 'posts' }] } })
|
|
5282
|
+
*
|
|
5283
|
+
* // Locking read inside a transaction (since 0.1.162) — see TReadLockOptions
|
|
5284
|
+
* const user = await table.findById('123', { lock: 'update' })
|
|
5218
5285
|
* ```
|
|
5219
5286
|
*/
|
|
5220
5287
|
async findById(id, query) {
|
|
5221
|
-
return this.findOneByRow(id, {
|
|
5288
|
+
return this.findOneByRow(id, {
|
|
5289
|
+
controls: query?.controls,
|
|
5290
|
+
lock: query?.lock,
|
|
5291
|
+
wait: query?.wait
|
|
5292
|
+
});
|
|
5222
5293
|
}
|
|
5223
5294
|
/**
|
|
5224
5295
|
* Reads the ONE row an id addresses — resolved exactly like
|
|
@@ -5229,14 +5300,37 @@ var AtscriptDbReadable = class {
|
|
|
5229
5300
|
* must also match `opts.scope` (an out-of-scope row answers `null` like a
|
|
5230
5301
|
* missing one).
|
|
5231
5302
|
*
|
|
5303
|
+
* `opts.lock` / `opts.wait` (since 0.1.162) lock the row found — see
|
|
5304
|
+
* {@link TReadLockOptions}. The id is then resolved to its one row first
|
|
5305
|
+
* (an unlocked read, exactly like {@link resolveRowFilter}) and that row is
|
|
5306
|
+
* read with the lock: with `wait: 'skipLocked'` a row another transaction
|
|
5307
|
+
* holds answers `null` — never a lower-precedence identification naming a
|
|
5308
|
+
* different row.
|
|
5309
|
+
*
|
|
5232
5310
|
* @since 0.1.143
|
|
5233
5311
|
*/
|
|
5234
5312
|
async findOneByRow(id, opts) {
|
|
5235
5313
|
this._ensureBuilt();
|
|
5236
5314
|
const controls = opts?.controls ?? {};
|
|
5237
|
-
|
|
5315
|
+
const candidates = this._idCandidates(id, opts);
|
|
5316
|
+
if (opts?.lock !== void 0 || opts?.wait !== void 0) {
|
|
5317
|
+
const lock = this._readLock({
|
|
5318
|
+
filter: {},
|
|
5319
|
+
controls
|
|
5320
|
+
}, opts);
|
|
5321
|
+
const pinned = await this._pinIdCandidates(candidates, opts?.scope);
|
|
5322
|
+
if (!pinned) return null;
|
|
5323
|
+
const query = {
|
|
5324
|
+
filter: this._andScope(pinned, opts?.scope),
|
|
5325
|
+
controls
|
|
5326
|
+
};
|
|
5327
|
+
this._guardQuery(query);
|
|
5328
|
+
return this._findOne(query, lock);
|
|
5329
|
+
}
|
|
5330
|
+
for (const candidate of candidates) {
|
|
5331
|
+
const filter = this._andScope(candidate, opts?.scope);
|
|
5238
5332
|
const row = await this.findOne({
|
|
5239
|
-
filter
|
|
5333
|
+
filter,
|
|
5240
5334
|
controls
|
|
5241
5335
|
});
|
|
5242
5336
|
if (row) return row;
|
|
@@ -5440,7 +5534,7 @@ var AtscriptDbReadable = class {
|
|
|
5440
5534
|
* Public entry point for relation loading. Used by adapters for nested $with delegation.
|
|
5441
5535
|
*/
|
|
5442
5536
|
async loadRelations(rows, withRelations) {
|
|
5443
|
-
const { loadRelationsImpl } = await import("./relation-loader-
|
|
5537
|
+
const { loadRelationsImpl } = await import("./relation-loader-Qv3LrQ42.mjs").then((n) => n.n);
|
|
5444
5538
|
return loadRelationsImpl(rows, withRelations, this);
|
|
5445
5539
|
}
|
|
5446
5540
|
/**
|
|
@@ -5478,6 +5572,62 @@ var NativeIntegrity = class extends IntegrityStrategy {
|
|
|
5478
5572
|
}
|
|
5479
5573
|
};
|
|
5480
5574
|
//#endregion
|
|
5575
|
+
//#region src/schema/fk-diff.ts
|
|
5576
|
+
/** Canonical key for an FK: sorted local field names, comma-joined. */
|
|
5577
|
+
function fkKey(fields) {
|
|
5578
|
+
return [...fields].toSorted().join(",");
|
|
5579
|
+
}
|
|
5580
|
+
/**
|
|
5581
|
+
* Physical local / target column names of a desired FK (`@db.column` renames
|
|
5582
|
+
* applied) — what DDL, constraint sync, the FK diff and the snapshot compare.
|
|
5583
|
+
*/
|
|
5584
|
+
function fkColumns(fk) {
|
|
5585
|
+
return {
|
|
5586
|
+
fields: fk.physicalFields ?? fk.fields,
|
|
5587
|
+
targetFields: fk.physicalTargetFields ?? fk.targetFields
|
|
5588
|
+
};
|
|
5589
|
+
}
|
|
5590
|
+
/**
|
|
5591
|
+
* Compares desired FK constraints against stored snapshot to detect
|
|
5592
|
+
* additions, removals, and property changes (target table, target fields,
|
|
5593
|
+
* onDelete, onUpdate).
|
|
5594
|
+
*/
|
|
5595
|
+
function computeForeignKeyDiff(desired, existingSnapshot) {
|
|
5596
|
+
const added = [];
|
|
5597
|
+
const removed = [];
|
|
5598
|
+
const changed = [];
|
|
5599
|
+
const existingByKey = /* @__PURE__ */ new Map();
|
|
5600
|
+
for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
|
|
5601
|
+
const desiredKeys = /* @__PURE__ */ new Set();
|
|
5602
|
+
for (const fk of desired.values()) {
|
|
5603
|
+
const key = fkKey(fkColumns(fk).fields);
|
|
5604
|
+
desiredKeys.add(key);
|
|
5605
|
+
const existing = existingByKey.get(key);
|
|
5606
|
+
if (!existing) added.push(fk);
|
|
5607
|
+
else if (fkPropertiesDiffer(fk, existing)) changed.push({
|
|
5608
|
+
desired: fk,
|
|
5609
|
+
existing
|
|
5610
|
+
});
|
|
5611
|
+
}
|
|
5612
|
+
for (const [key, fk] of existingByKey) if (!desiredKeys.has(key)) removed.push(fk);
|
|
5613
|
+
return {
|
|
5614
|
+
added,
|
|
5615
|
+
removed,
|
|
5616
|
+
changed
|
|
5617
|
+
};
|
|
5618
|
+
}
|
|
5619
|
+
/** Whether the FK diff contains any changes. */
|
|
5620
|
+
function hasForeignKeyChanges(diff) {
|
|
5621
|
+
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
5622
|
+
}
|
|
5623
|
+
function fkPropertiesDiffer(desired, existing) {
|
|
5624
|
+
if (desired.targetTable !== existing.targetTable) return true;
|
|
5625
|
+
if (fkKey(fkColumns(desired).targetFields) !== fkKey(existing.targetFields)) return true;
|
|
5626
|
+
if ((desired.onDelete ?? void 0) !== (existing.onDelete ?? void 0)) return true;
|
|
5627
|
+
if ((desired.onUpdate ?? void 0) !== (existing.onUpdate ?? void 0)) return true;
|
|
5628
|
+
return false;
|
|
5629
|
+
}
|
|
5630
|
+
//#endregion
|
|
5481
5631
|
//#region src/shared/failure-collector.ts
|
|
5482
5632
|
/**
|
|
5483
5633
|
* Collects failures from a batch of independent operations so one failing
|
|
@@ -5493,8 +5643,10 @@ function createFailureCollector(what) {
|
|
|
5493
5643
|
attempt: async (label, op) => {
|
|
5494
5644
|
try {
|
|
5495
5645
|
await op();
|
|
5646
|
+
return true;
|
|
5496
5647
|
} catch (error) {
|
|
5497
5648
|
failures.push(`${label}: ${error.message}`);
|
|
5649
|
+
return false;
|
|
5498
5650
|
}
|
|
5499
5651
|
},
|
|
5500
5652
|
throwIfAny: () => {
|
|
@@ -5502,6 +5654,27 @@ function createFailureCollector(what) {
|
|
|
5502
5654
|
}
|
|
5503
5655
|
};
|
|
5504
5656
|
}
|
|
5657
|
+
/**
|
|
5658
|
+
* Records the changes a sync pass makes — or, in a dry run, would make — so
|
|
5659
|
+
* planning and syncing share one code path. `apply(change, label, op)`
|
|
5660
|
+
* records `change` straight away in a dry run; otherwise it runs `op` first:
|
|
5661
|
+
* through `attempt` when given (a {@link createFailureCollector} collector —
|
|
5662
|
+
* a failure is collected and the change not recorded), else directly (a
|
|
5663
|
+
* failure throws).
|
|
5664
|
+
* @since 0.1.162
|
|
5665
|
+
*/
|
|
5666
|
+
function createChangeRecorder(dryRun, attempt) {
|
|
5667
|
+
const changes = [];
|
|
5668
|
+
return {
|
|
5669
|
+
changes,
|
|
5670
|
+
apply: async (change, label, op) => {
|
|
5671
|
+
if (!dryRun) if (attempt) {
|
|
5672
|
+
if (!await attempt(label, async () => op())) return;
|
|
5673
|
+
} else await op();
|
|
5674
|
+
changes.push(change);
|
|
5675
|
+
}
|
|
5676
|
+
};
|
|
5677
|
+
}
|
|
5505
5678
|
//#endregion
|
|
5506
5679
|
//#region src/base-adapter.ts
|
|
5507
5680
|
const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
|
|
@@ -5512,6 +5685,32 @@ const EMPTY_BUCKET_UNITS = /* @__PURE__ */ new Set();
|
|
|
5512
5685
|
*/
|
|
5513
5686
|
const ALL_BUCKET_UNITS = new Set(BUCKET_UNITS);
|
|
5514
5687
|
const NO_VIEW_CAPABILITIES = /* @__PURE__ */ new Set();
|
|
5688
|
+
/**
|
|
5689
|
+
* Every isolation level, weakest first — what an adapter that provides them
|
|
5690
|
+
* all returns from `isolationLevels()`. @since 0.1.162
|
|
5691
|
+
*/
|
|
5692
|
+
const ALL_ISOLATION_LEVELS = new Set([
|
|
5693
|
+
"read committed",
|
|
5694
|
+
"repeatable read",
|
|
5695
|
+
"serializable"
|
|
5696
|
+
]);
|
|
5697
|
+
const NO_ISOLATION_LEVELS = /* @__PURE__ */ new Set();
|
|
5698
|
+
/** Strength of each isolation level (higher = stricter). */
|
|
5699
|
+
const ISOLATION_RANK = {
|
|
5700
|
+
"read committed": 1,
|
|
5701
|
+
"repeatable read": 2,
|
|
5702
|
+
serializable: 3
|
|
5703
|
+
};
|
|
5704
|
+
/**
|
|
5705
|
+
* Validates `opts.isolation`: `undefined` when none is requested.
|
|
5706
|
+
* @throws DbError `INVALID_QUERY` for an unknown level.
|
|
5707
|
+
*/
|
|
5708
|
+
function requestedIsolation(opts) {
|
|
5709
|
+
const level = opts?.isolation;
|
|
5710
|
+
if (level === void 0) return void 0;
|
|
5711
|
+
if (!ALL_ISOLATION_LEVELS.has(level)) throw invalidQuery("isolation", `Unknown isolation level "${String(level)}" — use 'read committed', 'repeatable read' or 'serializable'`);
|
|
5712
|
+
return level;
|
|
5713
|
+
}
|
|
5515
5714
|
/** Every view capability — what the bundled adapters return from `viewCapabilities()`. @since 0.1.147 */
|
|
5516
5715
|
const ALL_VIEW_CAPABILITIES = new Set(["compute", "firstJoin"]);
|
|
5517
5716
|
const txStorage = new AsyncLocalStorage();
|
|
@@ -5519,6 +5718,12 @@ const txStorage = new AsyncLocalStorage();
|
|
|
5519
5718
|
function findTxContext(owner) {
|
|
5520
5719
|
for (let ctx = txStorage.getStore(); ctx; ctx = ctx.parent) if (ctx.owner === owner) return ctx;
|
|
5521
5720
|
}
|
|
5721
|
+
/** Skip verdict of the deprecated `shouldSkipType` / `warnUnsupportedTypes` options. */
|
|
5722
|
+
function legacySkipReason(opts, index) {
|
|
5723
|
+
const unsupported = opts.warnUnsupportedTypes;
|
|
5724
|
+
if (unsupported?.types.includes(index.type)) return `[${unsupported.adapter}] ${index.type} index "${index.name}" declared but not supported by this adapter — skipped`;
|
|
5725
|
+
return opts.shouldSkipType?.(index.type) ? null : void 0;
|
|
5726
|
+
}
|
|
5522
5727
|
/**
|
|
5523
5728
|
* Abstract base class for database adapters.
|
|
5524
5729
|
*
|
|
@@ -5621,6 +5826,41 @@ var BaseDbAdapter = class {
|
|
|
5621
5826
|
if (expectedVersion !== void 0) throw new Error("keepVersion cannot combine with expectedVersion");
|
|
5622
5827
|
}
|
|
5623
5828
|
/**
|
|
5829
|
+
* `@db.onUpdate.inc` columns (physical names, since 0.1.162) as a field-op
|
|
5830
|
+
* increment by one, or `undefined` when the table declares none. A replace
|
|
5831
|
+
* path renders it next to the replacement data (the core leaves these
|
|
5832
|
+
* columns out of the payload); the patch paths get the `$inc` from the core.
|
|
5833
|
+
*/
|
|
5834
|
+
_onUpdateIncOps() {
|
|
5835
|
+
const cols = this._table.onUpdateIncPhysical;
|
|
5836
|
+
if (cols.length === 0) return void 0;
|
|
5837
|
+
const inc = {};
|
|
5838
|
+
for (const col of cols) inc[col] = 1;
|
|
5839
|
+
return { inc };
|
|
5840
|
+
}
|
|
5841
|
+
/**
|
|
5842
|
+
* Whether `updateMany` / `deleteMany` render a row bound
|
|
5843
|
+
* ({@link TDbWriteLimit}) natively in one statement (since 0.1.162). When
|
|
5844
|
+
* `false` (the default) the core emulates a bounded write: it reads the
|
|
5845
|
+
* primary keys of the first `limit` matching rows in order, then writes the
|
|
5846
|
+
* rows matching the filter AND those keys.
|
|
5847
|
+
*/
|
|
5848
|
+
supportsWriteLimit() {
|
|
5849
|
+
return false;
|
|
5850
|
+
}
|
|
5851
|
+
/**
|
|
5852
|
+
* Whether a native bounded write ({@link supportsWriteLimit}) picks and
|
|
5853
|
+
* writes its rows atomically — a row a concurrent transaction deletes or
|
|
5854
|
+
* moves out of the filter is passed over and the next match taken, so `0`
|
|
5855
|
+
* affected rows always means nothing matched (MySQL's
|
|
5856
|
+
* `UPDATE/DELETE … ORDER BY … LIMIT`). When `false` (the default) the core
|
|
5857
|
+
* probes for a remaining match after a bounded write that affected no rows
|
|
5858
|
+
* and retries it a few times (since 0.1.162).
|
|
5859
|
+
*/
|
|
5860
|
+
writeLimitIsAtomic() {
|
|
5861
|
+
return false;
|
|
5862
|
+
}
|
|
5863
|
+
/**
|
|
5624
5864
|
* Logs a debug message if verbose mode is enabled.
|
|
5625
5865
|
* Adapters call this to log DB operations with zero overhead when disabled.
|
|
5626
5866
|
*/
|
|
@@ -5637,16 +5877,29 @@ var BaseDbAdapter = class {
|
|
|
5637
5877
|
* The generic layer handles nesting detection via `AsyncLocalStorage`.
|
|
5638
5878
|
* Adapters override `_beginTransaction`, `_commitTransaction`, and
|
|
5639
5879
|
* `_rollbackTransaction` to provide raw DB-specific transaction primitives.
|
|
5640
|
-
|
|
5641
|
-
|
|
5880
|
+
*
|
|
5881
|
+
* `opts.isolation` (since 0.1.162) sets the isolation level of the
|
|
5882
|
+
* transaction this call opens — "at least this level". An adapter whose
|
|
5883
|
+
* {@link isolationLevels} lacks it throws `ISOLATION_NOT_SUPPORTED`. A
|
|
5884
|
+
* nested call joins the open transaction; it may request a level only when
|
|
5885
|
+
* that transaction was opened with the same or a stricter one, else it
|
|
5886
|
+
* throws `INVALID_QUERY` (the level of a running transaction cannot change).
|
|
5887
|
+
*/
|
|
5888
|
+
async withTransaction(fn, opts) {
|
|
5642
5889
|
const owner = this._transactionOwner();
|
|
5643
|
-
|
|
5890
|
+
const isolation = this._checkIsolation(opts);
|
|
5891
|
+
const open = findTxContext(owner);
|
|
5892
|
+
if (open) {
|
|
5893
|
+
if (isolation) this._assertJoinIsolation(open.isolation, isolation);
|
|
5894
|
+
return fn();
|
|
5895
|
+
}
|
|
5644
5896
|
const ctx = {
|
|
5645
5897
|
owner,
|
|
5646
5898
|
state: void 0,
|
|
5647
|
-
parent: txStorage.getStore()
|
|
5899
|
+
parent: txStorage.getStore(),
|
|
5900
|
+
isolation: this._inherentIsolation() ?? isolation
|
|
5648
5901
|
};
|
|
5649
|
-
ctx.state = await this._beginTransaction();
|
|
5902
|
+
ctx.state = await this._beginTransaction(isolation ? { isolation } : void 0);
|
|
5650
5903
|
return txStorage.run(ctx, async () => {
|
|
5651
5904
|
try {
|
|
5652
5905
|
const result = await fn();
|
|
@@ -5661,6 +5914,67 @@ var BaseDbAdapter = class {
|
|
|
5661
5914
|
});
|
|
5662
5915
|
}
|
|
5663
5916
|
/**
|
|
5917
|
+
* The isolation levels {@link withTransaction} can open a transaction at
|
|
5918
|
+
* (since 0.1.162). Default: none — a requested level throws
|
|
5919
|
+
* `ISOLATION_NOT_SUPPORTED`. Override and apply the level in
|
|
5920
|
+
* {@link _beginTransaction} (`opts.isolation`).
|
|
5921
|
+
*/
|
|
5922
|
+
isolationLevels() {
|
|
5923
|
+
return NO_ISOLATION_LEVELS;
|
|
5924
|
+
}
|
|
5925
|
+
/**
|
|
5926
|
+
* The level every transaction of this adapter runs at regardless of the
|
|
5927
|
+
* request (since 0.1.162) — SQLite's `BEGIN IMMEDIATE` is always
|
|
5928
|
+
* serializable. A nested `withTransaction` asking for any level then joins
|
|
5929
|
+
* without error. Default `undefined` (the engine default / what was asked).
|
|
5930
|
+
*/
|
|
5931
|
+
_inherentIsolation() {}
|
|
5932
|
+
/**
|
|
5933
|
+
* Validates a `withTransaction` isolation request against
|
|
5934
|
+
* {@link isolationLevels} — the requested level, or `undefined`.
|
|
5935
|
+
* @throws DbError `INVALID_QUERY` (unknown level) / `ISOLATION_NOT_SUPPORTED`.
|
|
5936
|
+
*/
|
|
5937
|
+
_checkIsolation(opts) {
|
|
5938
|
+
const level = requestedIsolation(opts);
|
|
5939
|
+
if (level && !this.isolationLevels().has(level)) throw this._isolationNotSupported(`${this.constructor.name} cannot run a transaction at isolation level '${level}'`);
|
|
5940
|
+
return level;
|
|
5941
|
+
}
|
|
5942
|
+
/** The `ISOLATION_NOT_SUPPORTED` error of a `withTransaction` isolation request. */
|
|
5943
|
+
_isolationNotSupported(message) {
|
|
5944
|
+
return new DbError("ISOLATION_NOT_SUPPORTED", [{
|
|
5945
|
+
path: "isolation",
|
|
5946
|
+
message
|
|
5947
|
+
}]);
|
|
5948
|
+
}
|
|
5949
|
+
/**
|
|
5950
|
+
* A nested `withTransaction` asked for `requested` while a transaction
|
|
5951
|
+
* opened at `open` runs: fine when `open` is at least as strict.
|
|
5952
|
+
* @throws DbError `INVALID_QUERY` otherwise.
|
|
5953
|
+
*/
|
|
5954
|
+
_assertJoinIsolation(open, requested) {
|
|
5955
|
+
if (open && ISOLATION_RANK[open] >= ISOLATION_RANK[requested]) return;
|
|
5956
|
+
throw invalidQuery("isolation", `A nested withTransaction cannot request isolation '${requested}': the open transaction runs at ${open ? `'${open}'` : "the engine default"} — set the level on the outermost call`);
|
|
5957
|
+
}
|
|
5958
|
+
/**
|
|
5959
|
+
* Whether the readable layer may pass a row lock on `DbQuery.lock` to
|
|
5960
|
+
* `findOne` / `findMany` (since 0.1.162). Default `false` — a locking read
|
|
5961
|
+
* throws `LOCK_NOT_SUPPORTED`. An adapter whose transaction already
|
|
5962
|
+
* serialises writers may return `true` and ignore the lock.
|
|
5963
|
+
*/
|
|
5964
|
+
supportsReadLocks() {
|
|
5965
|
+
return false;
|
|
5966
|
+
}
|
|
5967
|
+
/**
|
|
5968
|
+
* `true` inside a `withTransaction` scope of this adapter's owner — a real
|
|
5969
|
+
* transaction or a pass-through one (adapters without transaction
|
|
5970
|
+
* primitives, such as the in-memory one). Unlike {@link isInTransaction} a
|
|
5971
|
+
* pass-through scope counts. Used to require a scope for locking reads.
|
|
5972
|
+
* @since 0.1.162
|
|
5973
|
+
*/
|
|
5974
|
+
inTransactionScope() {
|
|
5975
|
+
return findTxContext(this._transactionOwner()) !== void 0;
|
|
5976
|
+
}
|
|
5977
|
+
/**
|
|
5664
5978
|
* The object a transaction state is branded with (since 0.1.128). Every
|
|
5665
5979
|
* adapter instance that returns the same owner shares one transaction —
|
|
5666
5980
|
* override to return the driver / pool / client the adapter was constructed
|
|
@@ -5707,9 +6021,10 @@ var BaseDbAdapter = class {
|
|
|
5707
6021
|
}
|
|
5708
6022
|
/**
|
|
5709
6023
|
* Starts a raw transaction. Returns opaque state stored in the async context.
|
|
5710
|
-
* Override in adapters that support transactions.
|
|
6024
|
+
* Override in adapters that support transactions. `opts.isolation` (since
|
|
6025
|
+
* 0.1.162) is set only to a level of {@link isolationLevels}.
|
|
5711
6026
|
*/
|
|
5712
|
-
async _beginTransaction() {}
|
|
6027
|
+
async _beginTransaction(_opts) {}
|
|
5713
6028
|
/** Commits the raw transaction. Override in adapters that support transactions. */
|
|
5714
6029
|
async _commitTransaction(_state) {}
|
|
5715
6030
|
/** Rolls back the raw transaction. Override in adapters that support transactions. */
|
|
@@ -6015,46 +6330,88 @@ var BaseDbAdapter = class {
|
|
|
6015
6330
|
*
|
|
6016
6331
|
* @example
|
|
6017
6332
|
* ```typescript
|
|
6018
|
-
*
|
|
6019
|
-
*
|
|
6333
|
+
* protected override _syncIndexes(dryRun: boolean) {
|
|
6334
|
+
* return this.syncIndexesWithDiff({
|
|
6335
|
+
* dryRun,
|
|
6020
6336
|
* listExisting: async () => this.driver.all('PRAGMA index_list(...)'),
|
|
6021
6337
|
* createIndex: async (index) => this.driver.exec('CREATE INDEX ...'),
|
|
6022
6338
|
* dropIndex: async (name) => this.driver.exec('DROP INDEX ...'),
|
|
6023
|
-
*
|
|
6339
|
+
* skipIndex: (index) => (index.type === 'fulltext' ? null : undefined),
|
|
6024
6340
|
* })
|
|
6025
6341
|
* }
|
|
6026
6342
|
* ```
|
|
6027
6343
|
*/
|
|
6028
6344
|
async syncIndexesWithDiff(opts) {
|
|
6029
6345
|
const prefix = opts.prefix ?? "atscript__";
|
|
6030
|
-
const
|
|
6346
|
+
const dryRun = opts.dryRun === true;
|
|
6347
|
+
const managed = (await opts.listExisting()).filter((i) => i.name.startsWith(prefix) && !(opts.isUnmanaged?.(i.name) ?? false));
|
|
6031
6348
|
const existingNames = new Set(managed.map((i) => i.name));
|
|
6032
6349
|
const existingColumns = new Map(managed.filter((i) => i.columns).map((i) => [i.name, i.columns]));
|
|
6033
6350
|
const desiredNames = /* @__PURE__ */ new Set();
|
|
6034
6351
|
const { attempt, throwIfAny } = createFailureCollector("index sync");
|
|
6352
|
+
const { changes, apply } = createChangeRecorder(dryRun, attempt);
|
|
6035
6353
|
for (const index of this._table.indexes.values()) {
|
|
6036
|
-
if (
|
|
6037
|
-
|
|
6354
|
+
if (index.type === "fulltext" && !index.fields.some((f) => !f.integer)) continue;
|
|
6355
|
+
let skipReason = opts.skipIndex?.(index);
|
|
6356
|
+
if (skipReason === void 0) skipReason = legacySkipReason(opts, index);
|
|
6357
|
+
if (skipReason !== void 0) {
|
|
6358
|
+
if (skipReason !== null && !dryRun) this.logger.warn(skipReason);
|
|
6038
6359
|
continue;
|
|
6039
6360
|
}
|
|
6040
|
-
if (opts.shouldSkipType?.(index.type)) continue;
|
|
6041
|
-
if (index.type === "fulltext" && !index.fields.some((f) => !f.integer)) continue;
|
|
6042
6361
|
desiredNames.add(index.key);
|
|
6043
6362
|
if (!existingNames.has(index.key)) {
|
|
6044
|
-
await
|
|
6363
|
+
await apply({
|
|
6364
|
+
name: index.key,
|
|
6365
|
+
action: "add"
|
|
6366
|
+
}, `create index "${index.key}"`, () => opts.createIndex(index));
|
|
6045
6367
|
continue;
|
|
6046
6368
|
}
|
|
6047
6369
|
if (index.type === "plain" || index.type === "unique") {
|
|
6048
6370
|
const liveColumns = existingColumns.get(index.key);
|
|
6049
6371
|
const desiredColumns = index.fields.map((f) => opts.renderDesiredColumn?.(index, f) ?? f.name);
|
|
6050
|
-
if (liveColumns && (liveColumns.length !== desiredColumns.length || liveColumns.some((c, i) => c !== desiredColumns[i]))) await
|
|
6372
|
+
if (liveColumns && (liveColumns.length !== desiredColumns.length || liveColumns.some((c, i) => c !== desiredColumns[i]))) await apply({
|
|
6373
|
+
name: index.key,
|
|
6374
|
+
action: "rebuild"
|
|
6375
|
+
}, `rebuild index "${index.key}"`, async () => {
|
|
6051
6376
|
await opts.dropIndex(index.key);
|
|
6052
6377
|
await opts.createIndex(index);
|
|
6053
6378
|
});
|
|
6054
6379
|
}
|
|
6055
6380
|
}
|
|
6056
|
-
for (const name of existingNames) if (!desiredNames.has(name)) await
|
|
6381
|
+
for (const name of existingNames) if (!desiredNames.has(name)) await apply({
|
|
6382
|
+
name,
|
|
6383
|
+
action: "drop"
|
|
6384
|
+
}, `drop index "${name}"`, () => opts.dropIndex(name));
|
|
6057
6385
|
throwIfAny();
|
|
6386
|
+
return changes;
|
|
6387
|
+
}
|
|
6388
|
+
/**
|
|
6389
|
+
* Template method for foreign-key constraint synchronization: drops the
|
|
6390
|
+
* live constraints the model does not declare and adds the declared ones
|
|
6391
|
+
* the live table lacks — matched by their sorted local columns
|
|
6392
|
+
* ({@link fkKey}). A failing statement throws. Returns the changes (made,
|
|
6393
|
+
* or with `dryRun` only computed).
|
|
6394
|
+
* @since 0.1.162
|
|
6395
|
+
*/
|
|
6396
|
+
async syncForeignKeysWithDiff(opts) {
|
|
6397
|
+
const { changes, apply } = createChangeRecorder(opts.dryRun === true);
|
|
6398
|
+
const existing = await opts.listExisting();
|
|
6399
|
+
const desired = [...this._table.foreignKeys.values()];
|
|
6400
|
+
const desiredKeys = new Set(desired.map((fk) => fkKey(fkColumns(fk).fields)));
|
|
6401
|
+
for (const [name, columns] of existing) if (!desiredKeys.has(fkKey(columns))) await apply({
|
|
6402
|
+
fields: columns,
|
|
6403
|
+
action: "drop"
|
|
6404
|
+
}, `drop foreign key "${name}"`, () => opts.dropConstraint(name));
|
|
6405
|
+
const existingKeys = new Set([...existing.values()].map((columns) => fkKey(columns)));
|
|
6406
|
+
for (const fk of desired) {
|
|
6407
|
+
const { fields } = fkColumns(fk);
|
|
6408
|
+
if (!existingKeys.has(fkKey(fields))) await apply({
|
|
6409
|
+
fields,
|
|
6410
|
+
targetTable: fk.targetTable,
|
|
6411
|
+
action: "add"
|
|
6412
|
+
}, `add foreign key (${fields.join(", ")})`, () => opts.addConstraint(fk));
|
|
6413
|
+
}
|
|
6414
|
+
return changes;
|
|
6058
6415
|
}
|
|
6059
6416
|
/**
|
|
6060
6417
|
* Returns available search indexes for this adapter.
|
|
@@ -6273,6 +6630,38 @@ var BaseDbAdapter = class {
|
|
|
6273
6630
|
return false;
|
|
6274
6631
|
}
|
|
6275
6632
|
/**
|
|
6633
|
+
* Synchronizes indexes between the Atscript definitions and the database.
|
|
6634
|
+
* Uses `this._table.indexes` for the full index definitions. Returns the
|
|
6635
|
+
* changes it made (since 0.1.162) — schema sync reports them on the run
|
|
6636
|
+
* entry; `void` reports none. Schema sync
|
|
6637
|
+
* passes `opts` when safe mode skipped work the indexes depend on (see
|
|
6638
|
+
* {@link TSyncIndexesOptions}); an adapter whose indexes do not depend on it
|
|
6639
|
+
* ignores them. Default: {@link _syncIndexes} without `dryRun`.
|
|
6640
|
+
*/
|
|
6641
|
+
async syncIndexes(opts) {
|
|
6642
|
+
return this._syncIndexes(false, opts);
|
|
6643
|
+
}
|
|
6644
|
+
/**
|
|
6645
|
+
* The index changes {@link syncIndexes} would make now — no DDL. Schema
|
|
6646
|
+
* sync's plan reports them (`entry.indexChanges`) and marks the entry
|
|
6647
|
+
* `alter`; a run reports what `syncIndexes` returns.
|
|
6648
|
+
* @since 0.1.162
|
|
6649
|
+
*/
|
|
6650
|
+
async planIndexes(opts) {
|
|
6651
|
+
return this._syncIndexes(true, opts);
|
|
6652
|
+
}
|
|
6653
|
+
/**
|
|
6654
|
+
* The one index pass behind {@link syncIndexes} (`dryRun: false`) and
|
|
6655
|
+
* {@link planIndexes} (`dryRun: true` — compute the changes, no DDL).
|
|
6656
|
+
* Adapters implement this (usually over {@link syncIndexesWithDiff}). An
|
|
6657
|
+
* adapter that overrides `syncIndexes` instead plans no index work.
|
|
6658
|
+
* @since 0.1.162
|
|
6659
|
+
*/
|
|
6660
|
+
async _syncIndexes(dryRun, _opts) {
|
|
6661
|
+
if (dryRun) return [];
|
|
6662
|
+
throw new Error(`${this.constructor.name} implements neither syncIndexes nor _syncIndexes`);
|
|
6663
|
+
}
|
|
6664
|
+
/**
|
|
6276
6665
|
* When true, the adapter can handle column type changes in-place
|
|
6277
6666
|
* (e.g. MySQL's ALTER TABLE MODIFY COLUMN) without requiring table recreation.
|
|
6278
6667
|
* The generic sync layer will delegate type changes to {@link syncColumns}
|
|
@@ -6817,7 +7206,7 @@ function assertNoVersionWrites(data, versionColumn) {
|
|
|
6817
7206
|
* Whether a logical (pre-decomposition) patch writes ONLY version-exempt
|
|
6818
7207
|
* fields (`@db.column.version.exempt`). `data` has nav fields, filter keys and
|
|
6819
7208
|
* derived fields removed and `$cas` separated. Mirrors `flattenPatchPayload`
|
|
6820
|
-
* (keep the two side by side): PK keys and `@db.onUpdate.now` fields are
|
|
7209
|
+
* (keep the two side by side): PK keys and `@db.onUpdate.now` / `.inc` fields are
|
|
6821
7210
|
* skipped; an exempt path (or one under an exempt ancestor) is covered
|
|
6822
7211
|
* whatever its value (scalar, field op, array ops, object); a non-exempt
|
|
6823
7212
|
* merge-strategy object recurses (only the supplied children are written);
|
|
@@ -6831,11 +7220,12 @@ function isVersionExemptPatch(data, table) {
|
|
|
6831
7220
|
const primaryKeys = meta.primaryKeys;
|
|
6832
7221
|
const flatMap = meta.flatMap;
|
|
6833
7222
|
const stamps = meta.onUpdateNow;
|
|
7223
|
+
const counters = meta.onUpdateInc;
|
|
6834
7224
|
const walk = (obj, prefix) => {
|
|
6835
7225
|
let result = "none";
|
|
6836
7226
|
for (const [k, value] of Object.entries(obj)) {
|
|
6837
7227
|
const key = prefix ? `${prefix}.${k}` : k;
|
|
6838
|
-
if (primaryKeys.includes(key) || stamps.has(key)) continue;
|
|
7228
|
+
if (primaryKeys.includes(key) || stamps.has(key) || counters.has(key)) continue;
|
|
6839
7229
|
if (meta.isVersionExemptPath(key)) {
|
|
6840
7230
|
result = "exempt";
|
|
6841
7231
|
continue;
|
|
@@ -7062,15 +7452,47 @@ function concurrentChange() {
|
|
|
7062
7452
|
}
|
|
7063
7453
|
/** Hand-off for a version-exempt patch (since 0.1.150) — shared, never mutated. */
|
|
7064
7454
|
const KEEP_VERSION = Object.freeze({ keepVersion: true });
|
|
7065
|
-
/**
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7455
|
+
/**
|
|
7456
|
+
* The trailing `TDbUpdateOptions` rest-arg of an adapter update: `[]` when
|
|
7457
|
+
* there is nothing to pass, so the common call shape stays unchanged.
|
|
7458
|
+
*/
|
|
7459
|
+
function updateOptsArg(keepVersion, limit) {
|
|
7460
|
+
if (limit) return [keepVersion ? {
|
|
7461
|
+
keepVersion,
|
|
7462
|
+
limit
|
|
7463
|
+
} : { limit }];
|
|
7464
|
+
return keepVersion ? [KEEP_VERSION] : [];
|
|
7465
|
+
}
|
|
7466
|
+
/** Extra attempts of a bounded write that affected no rows while matches remain. */
|
|
7467
|
+
const BOUNDED_WRITE_RETRIES = 3;
|
|
7468
|
+
/**
|
|
7469
|
+
* A bounded write lost its picked rows to concurrent writers on every
|
|
7470
|
+
* attempt while rows still matched — retryable, like other row contention.
|
|
7471
|
+
*/
|
|
7472
|
+
function boundedWriteContention() {
|
|
7473
|
+
return new DbError("SERIALIZATION_FAILURE", [{
|
|
7474
|
+
path: "limit",
|
|
7475
|
+
message: `Bounded write: concurrent writers took the picked rows on 4 attempts while rows still matched — retry`
|
|
7072
7476
|
}]);
|
|
7073
7477
|
}
|
|
7478
|
+
const DELETE_RESULT = {
|
|
7479
|
+
affected: (r) => r.deletedCount,
|
|
7480
|
+
add: (a, b) => ({ deletedCount: a.deletedCount + b.deletedCount }),
|
|
7481
|
+
none: () => ({ deletedCount: 0 })
|
|
7482
|
+
};
|
|
7483
|
+
const UPDATE_RESULT = {
|
|
7484
|
+
affected: (r) => r.matchedCount,
|
|
7485
|
+
add: (a, b) => ({
|
|
7486
|
+
matchedCount: a.matchedCount + b.matchedCount,
|
|
7487
|
+
modifiedCount: a.modifiedCount + b.modifiedCount
|
|
7488
|
+
}),
|
|
7489
|
+
none: () => ({
|
|
7490
|
+
matchedCount: 0,
|
|
7491
|
+
modifiedCount: 0
|
|
7492
|
+
})
|
|
7493
|
+
};
|
|
7494
|
+
/** Upper bound of keys per `touchMany` UPDATE statement (parameter-count safety). */
|
|
7495
|
+
const TOUCH_MANY_CHUNK = 500;
|
|
7074
7496
|
var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
7075
7497
|
_cascadeResolver;
|
|
7076
7498
|
_fkLookupResolver;
|
|
@@ -7130,15 +7552,9 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7130
7552
|
async _insertMany(payloads, opts) {
|
|
7131
7553
|
this._ensureBuilt();
|
|
7132
7554
|
const { _depth, _action, maxDepth: userMax, guard, check, onConflict, lockConflicts } = opts ?? {};
|
|
7133
|
-
if (onConflict !== void 0 && onConflict !== "error" && onConflict !== "ignore") throw
|
|
7134
|
-
path: "onConflict",
|
|
7135
|
-
message: `Unknown onConflict mode "${String(onConflict)}"`
|
|
7136
|
-
}]);
|
|
7555
|
+
if (onConflict !== void 0 && onConflict !== "error" && onConflict !== "ignore") throw invalidQuery("onConflict", `Unknown onConflict mode "${String(onConflict)}"`);
|
|
7137
7556
|
const ignore = onConflict === "ignore";
|
|
7138
|
-
if (lockConflicts && !ignore) throw
|
|
7139
|
-
path: "lockConflicts",
|
|
7140
|
-
message: "lockConflicts requires onConflict \"ignore\""
|
|
7141
|
-
}]);
|
|
7557
|
+
if (lockConflicts && !ignore) throw invalidQuery("lockConflicts", "lockConflicts requires onConflict \"ignore\"");
|
|
7142
7558
|
const ignoreOpts = ignore ? { inCallerTransaction: this.adapter.isInTransaction() } : void 0;
|
|
7143
7559
|
if (ignoreOpts?.inCallerTransaction && lockConflicts) ignoreOpts.lockConflicts = true;
|
|
7144
7560
|
const maxDepth = userMax ?? 3;
|
|
@@ -7229,8 +7645,9 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7229
7645
|
const items = payloads.map((p, i) => {
|
|
7230
7646
|
const c = clone(p);
|
|
7231
7647
|
expectedVersions[i] = separateCas(c, versionColumn);
|
|
7232
|
-
this.
|
|
7233
|
-
|
|
7648
|
+
const d = this._applyDefaults(c, now);
|
|
7649
|
+
this._stampOnUpdate(d, now, true);
|
|
7650
|
+
return d;
|
|
7234
7651
|
});
|
|
7235
7652
|
const originals = canNest ? items.map((item) => ({ ...item })) : [];
|
|
7236
7653
|
const validator = this.getValidator("bulkReplace");
|
|
@@ -7364,8 +7781,8 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7364
7781
|
if (!matched[i] && _ownedBy?.strict?.[i]) throw concurrentChange();
|
|
7365
7782
|
continue;
|
|
7366
7783
|
}
|
|
7367
|
-
const updateOpts = expectedVersion === void 0 && versionColumn !== void 0 && isVersionExemptPatch(data, this)
|
|
7368
|
-
this._stampTopLevel(data, now);
|
|
7784
|
+
const updateOpts = updateOptsArg(expectedVersion === void 0 && versionColumn !== void 0 && isVersionExemptPatch(data, this));
|
|
7785
|
+
this._stampTopLevel(data, now, expectedVersion !== void 0);
|
|
7369
7786
|
let result;
|
|
7370
7787
|
if (this.adapter.supportsNativePatch()) {
|
|
7371
7788
|
const ops = separateFieldOps(data);
|
|
@@ -7429,7 +7846,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7429
7846
|
async touchMany(keys, opts) {
|
|
7430
7847
|
this._ensureBuilt();
|
|
7431
7848
|
const versionField = this._meta.versionField;
|
|
7432
|
-
if (versionField === void 0) throw
|
|
7849
|
+
if (versionField === void 0) throw invalidQuery("", "touchMany requires @db.column.version");
|
|
7433
7850
|
if (keys.length === 0) return {
|
|
7434
7851
|
matchedCount: 0,
|
|
7435
7852
|
modifiedCount: 0
|
|
@@ -7440,14 +7857,14 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7440
7857
|
for (const [i, key] of keys.entries()) {
|
|
7441
7858
|
const pair = {};
|
|
7442
7859
|
for (const pk of pkFields) {
|
|
7443
|
-
if (key[pk] === void 0) throw
|
|
7860
|
+
if (key[pk] === void 0) throw invalidQuery(`[${i}].${pk}`, `touchMany: each key must carry its "${pk}"`);
|
|
7444
7861
|
pair[pk] = key[pk];
|
|
7445
7862
|
}
|
|
7446
7863
|
const version = key[versionField];
|
|
7447
|
-
if (typeof version !== "number" || !Number.isFinite(version)) throw
|
|
7448
|
-
for (const [prop, value] of Object.entries(key)) if (value !== void 0 && prop !== versionField && !(prop in pair)) throw
|
|
7864
|
+
if (typeof version !== "number" || !Number.isFinite(version)) throw invalidQuery(`[${i}].${versionField}`, `touchMany: each key must carry its expected "${versionField}" (number)`);
|
|
7865
|
+
for (const [prop, value] of Object.entries(key)) if (value !== void 0 && prop !== versionField && !(prop in pair)) throw invalidQuery(`[${i}].${prop}`, `touchMany: a touch carries no payload — keys hold the primary key and "${versionField}" only, got "${prop}"`);
|
|
7449
7866
|
const identity = JSON.stringify(pair);
|
|
7450
|
-
if (seen.has(identity)) throw
|
|
7867
|
+
if (seen.has(identity)) throw invalidQuery(`[${i}]`, `touchMany: duplicate key ${identity}`);
|
|
7451
7868
|
seen.add(identity);
|
|
7452
7869
|
pair[versionField] = version;
|
|
7453
7870
|
pairs.push(pair);
|
|
@@ -7463,12 +7880,18 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7463
7880
|
});
|
|
7464
7881
|
if (matched < keys.length) throw new CasMismatchError(matched, keys.length);
|
|
7465
7882
|
}
|
|
7883
|
+
const stamps = {};
|
|
7884
|
+
this._stampTopLevel(stamps, Date.now(), true);
|
|
7885
|
+
const { update, ops } = isEmptyObject(stamps) ? {
|
|
7886
|
+
update: stamps,
|
|
7887
|
+
ops: void 0
|
|
7888
|
+
} : this._translatePatch(stamps);
|
|
7466
7889
|
return this.adapter.withTransaction(async () => {
|
|
7467
7890
|
let matchedCount = 0;
|
|
7468
7891
|
let modifiedCount = 0;
|
|
7469
7892
|
for (let start = 0; start < translated.length; start += TOUCH_MANY_CHUNK) {
|
|
7470
7893
|
const chunk = translated.slice(start, start + TOUCH_MANY_CHUNK);
|
|
7471
|
-
const result = await this.adapter.updateMany({ $or: chunk },
|
|
7894
|
+
const result = await this.adapter.updateMany({ $or: chunk }, update, ops);
|
|
7472
7895
|
matchedCount += result.matchedCount;
|
|
7473
7896
|
modifiedCount += result.modifiedCount;
|
|
7474
7897
|
}
|
|
@@ -7519,66 +7942,227 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7519
7942
|
};
|
|
7520
7943
|
return remapDeleteFkViolation(this.tableName, () => guard || needsCascade || candidates.length > 1 ? this.adapter.withTransaction(run) : run());
|
|
7521
7944
|
}
|
|
7522
|
-
|
|
7945
|
+
/**
|
|
7946
|
+
* Patches every row matching `filter` with `data` (own fields only — no
|
|
7947
|
+
* relations, no `$cas`). Since 0.1.162 the payload is validated in `patch`
|
|
7948
|
+
* mode, exactly like {@link updateOne}: a type mismatch, `null` on a
|
|
7949
|
+
* non-nullable field, `@meta.required` or `@expect.*` failures throw a
|
|
7950
|
+
* `ValidatorError` before any statement runs.
|
|
7951
|
+
*
|
|
7952
|
+
* `opts.limit` (since 0.1.162) bounds the write to the first `limit` matching
|
|
7953
|
+
* rows in `opts.sort` order (default: primary key ascending; the primary key
|
|
7954
|
+
* always breaks ties). Loop until `matchedCount === 0` to process every
|
|
7955
|
+
* match in batches. Not exposed over HTTP.
|
|
7956
|
+
*/
|
|
7957
|
+
async updateMany(filter, data, opts) {
|
|
7523
7958
|
this._ensureBuilt();
|
|
7524
7959
|
this._guardMutationFilter(filter);
|
|
7525
|
-
|
|
7960
|
+
const bound = this._writeBound(filter, opts);
|
|
7526
7961
|
const dataCopy = _cloneWritePayload(data);
|
|
7527
|
-
|
|
7962
|
+
if ("$cas" in dataCopy) throw invalidQuery("$cas", "$cas is not supported on updateMany — use bulkUpdate with per-row $cas for version-locked batch updates");
|
|
7963
|
+
this._rejectNavWrite(dataCopy, "updateMany");
|
|
7528
7964
|
const now = Date.now();
|
|
7529
7965
|
this._stampOnUpdate(dataCopy, now, false);
|
|
7966
|
+
validateBatch(this.getValidator("bulkUpdate"), [dataCopy], {
|
|
7967
|
+
mode: "patch",
|
|
7968
|
+
flatMap: this.flatMap,
|
|
7969
|
+
navFields: this._meta.navFields
|
|
7970
|
+
});
|
|
7971
|
+
await this._integrity.validateForeignKeys([dataCopy], this._meta, this._fkLookupResolver, this._writeTableResolver, true);
|
|
7972
|
+
this._meta.stripDerived(dataCopy);
|
|
7530
7973
|
const versionColumn = this.versionColumn;
|
|
7531
|
-
if ("$cas" in dataCopy) throw new DbError("INVALID_QUERY", [{
|
|
7532
|
-
path: "$cas",
|
|
7533
|
-
message: "$cas is not supported on updateMany — use bulkUpdate with per-row $cas for version-locked batch updates"
|
|
7534
|
-
}]);
|
|
7535
7974
|
if (versionColumn !== void 0) assertNoVersionWrites(dataCopy, versionColumn);
|
|
7536
|
-
const
|
|
7975
|
+
const keepVersion = versionColumn !== void 0 && isVersionExemptPatch(dataCopy, this);
|
|
7537
7976
|
this._stampTopLevel(dataCopy, now);
|
|
7538
7977
|
await this._encryptItems([dataCopy], "patch");
|
|
7539
|
-
const update =
|
|
7540
|
-
const
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
|
|
7544
|
-
if (translatedOps === void 0 && isEmptyObject(translatedUpdate)) return {
|
|
7545
|
-
matchedCount: await this.adapter.count({
|
|
7978
|
+
const { update, ops } = this._translatePatch(dataCopy);
|
|
7979
|
+
const translatedFilter = bound?.query.filter ?? this._fieldMapper.translateFilter(filter, this._meta);
|
|
7980
|
+
if (ops === void 0 && isEmptyObject(update)) {
|
|
7981
|
+
const matchedCount = await this.adapter.count({
|
|
7546
7982
|
filter: translatedFilter,
|
|
7547
7983
|
controls: {}
|
|
7548
|
-
})
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7984
|
+
});
|
|
7985
|
+
return {
|
|
7986
|
+
matchedCount: bound ? Math.min(matchedCount, bound.writeLimit.limit) : matchedCount,
|
|
7987
|
+
modifiedCount: 0
|
|
7988
|
+
};
|
|
7989
|
+
}
|
|
7990
|
+
return enrichFkViolation(this._meta, () => {
|
|
7991
|
+
if (!bound) return this.adapter.updateMany(translatedFilter, update, ops, ...updateOptsArg(keepVersion));
|
|
7992
|
+
return this._runBounded(bound, UPDATE_RESULT, (f, limit) => this.adapter.updateMany(f, update, ops, ...updateOptsArg(keepVersion, limit)));
|
|
7993
|
+
});
|
|
7552
7994
|
}
|
|
7995
|
+
/**
|
|
7996
|
+
* Replaces the own fields of every row matching `filter` with `data`. The
|
|
7997
|
+
* payload is validated in `replace` mode (since 0.1.162) — every required
|
|
7998
|
+
* field must be present — except the primary key, which the filter
|
|
7999
|
+
* replaces as a row picker.
|
|
8000
|
+
*/
|
|
7553
8001
|
async replaceMany(filter, data) {
|
|
7554
8002
|
this._ensureBuilt();
|
|
7555
8003
|
this._guardMutationFilter(filter);
|
|
7556
|
-
await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver);
|
|
7557
8004
|
const dataCopy = _cloneWritePayload(data);
|
|
8005
|
+
this._rejectNavWrite(dataCopy, "replaceMany");
|
|
7558
8006
|
this._stampOnUpdate(dataCopy, Date.now(), true);
|
|
8007
|
+
validateBatch(this.getValidator("bulkReplace"), [dataCopy], {
|
|
8008
|
+
mode: "replace",
|
|
8009
|
+
navFields: this._meta.navFields,
|
|
8010
|
+
keyless: true
|
|
8011
|
+
});
|
|
8012
|
+
await this._integrity.validateForeignKeys([dataCopy], this._meta, this._fkLookupResolver, this._writeTableResolver);
|
|
8013
|
+
const versionColumn = this.versionColumn;
|
|
8014
|
+
if (versionColumn !== void 0) assertNoVersionWrites(dataCopy, versionColumn);
|
|
7559
8015
|
await this._encryptItems([dataCopy], "write");
|
|
7560
8016
|
return enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite(dataCopy, this._meta, this.adapter)));
|
|
7561
8017
|
}
|
|
7562
|
-
|
|
8018
|
+
/**
|
|
8019
|
+
* Deletes every row matching `filter` (application-level cascades run first
|
|
8020
|
+
* where the adapter has no native foreign keys).
|
|
8021
|
+
*
|
|
8022
|
+
* `opts.limit` (since 0.1.162) bounds the delete to the first `limit`
|
|
8023
|
+
* matching rows in `opts.sort` order (default: primary key ascending; the
|
|
8024
|
+
* primary key always breaks ties) — for retention sweeps that keep each
|
|
8025
|
+
* statement small. Loop until `deletedCount === 0`, not until it is below
|
|
8026
|
+
* `limit`: a concurrent writer can make one round delete fewer rows while
|
|
8027
|
+
* matches remain. Not exposed over HTTP.
|
|
8028
|
+
*/
|
|
8029
|
+
async deleteMany(filter, opts) {
|
|
7563
8030
|
this._ensureBuilt();
|
|
7564
8031
|
this._guardMutationFilter(filter);
|
|
8032
|
+
const bound = this._writeBound(filter, opts);
|
|
8033
|
+
const translate = (f) => this._fieldMapper.translateFilter(f, this._meta);
|
|
7565
8034
|
if (this._integrity.needsCascade(this._cascadeResolver, this.tableName)) return remapDeleteFkViolation(this.tableName, () => this.adapter.withTransaction(async () => {
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
let deletedCount = 0;
|
|
7569
|
-
for (const batch of pin) deletedCount += (await this.adapter.deleteMany(batch)).deletedCount;
|
|
7570
|
-
return { deletedCount };
|
|
7571
|
-
}
|
|
7572
|
-
return this.adapter.deleteMany(this._fieldMapper.translateFilter(filter, this._meta));
|
|
8035
|
+
if (!bound) return this._cascadeDelete(filter, translate);
|
|
8036
|
+
return this._runBounded(bound, DELETE_RESULT, (f) => this._cascadeDelete(f, (pinned) => pinned), false);
|
|
7573
8037
|
}));
|
|
7574
|
-
return remapDeleteFkViolation(this.tableName, () => this.adapter.deleteMany(this.
|
|
8038
|
+
return remapDeleteFkViolation(this.tableName, () => bound ? this._runBounded(bound, DELETE_RESULT, (f, limit) => limit ? this.adapter.deleteMany(f, { limit }) : this.adapter.deleteMany(f)) : this.adapter.deleteMany(translate(filter)));
|
|
8039
|
+
}
|
|
8040
|
+
/**
|
|
8041
|
+
* Runs a bounded write. `native` (default: `supportsWriteLimit()`) hands
|
|
8042
|
+
* `write` the physical filter and the bound; otherwise the primary keys of
|
|
8043
|
+
* the picked rows are read first and `write` gets `filter AND keys` per
|
|
8044
|
+
* key batch (no bound) — a pick that finds nothing writes nothing.
|
|
8045
|
+
*
|
|
8046
|
+
* When the write affected no rows although a row still matches — a
|
|
8047
|
+
* concurrent transaction deleted or changed the picked rows between the
|
|
8048
|
+
* pick and the write — it runs again, at most
|
|
8049
|
+
* {@link BOUNDED_WRITE_RETRIES} more times; an emulated write's next pick
|
|
8050
|
+
* is that check. Rows still matching after the last attempt throw a
|
|
8051
|
+
* retryable `SERIALIZATION_FAILURE`. So `0` means nothing matched (a loop
|
|
8052
|
+
* until `0` cannot stop early); fewer than `limit` stays possible.
|
|
8053
|
+
* A native write whose adapter reports `writeLimitIsAtomic()` is never
|
|
8054
|
+
* re-checked.
|
|
8055
|
+
*/
|
|
8056
|
+
async _runBounded(bound, result, write, native = this.adapter.supportsWriteLimit()) {
|
|
8057
|
+
const atomic = native && this.adapter.writeLimitIsAtomic();
|
|
8058
|
+
const filter = bound.query.filter;
|
|
8059
|
+
const pkColumns = this._meta.primaryKeys.map((field) => this._meta.physicalPath(field));
|
|
8060
|
+
for (let attempt = 0;; attempt++) {
|
|
8061
|
+
let out;
|
|
8062
|
+
if (native) out = await write(filter, bound.writeLimit);
|
|
8063
|
+
else {
|
|
8064
|
+
const rows = await this.adapter.findMany(bound.query);
|
|
8065
|
+
if (rows.length === 0) return result.none();
|
|
8066
|
+
out = result.none();
|
|
8067
|
+
for (const keys of pinByPrimaryKey(rows, pkColumns)) out = result.add(out, await write({ $and: [filter, keys] }));
|
|
8068
|
+
}
|
|
8069
|
+
if (atomic || result.affected(out) > 0) return out;
|
|
8070
|
+
const last = attempt === BOUNDED_WRITE_RETRIES;
|
|
8071
|
+
if (native || last) {
|
|
8072
|
+
if (!await this._boundStillMatches(bound)) return out;
|
|
8073
|
+
if (last) throw boundedWriteContention();
|
|
8074
|
+
}
|
|
8075
|
+
}
|
|
8076
|
+
}
|
|
8077
|
+
/** Whether any row still matches a bounded write's filter (a one-row key read). */
|
|
8078
|
+
async _boundStillMatches(bound) {
|
|
8079
|
+
const { $sort: _sort, $nulls: _nulls, ...controls } = bound.query.controls;
|
|
8080
|
+
return (await this.adapter.findMany({
|
|
8081
|
+
...bound.query,
|
|
8082
|
+
controls: {
|
|
8083
|
+
...controls,
|
|
8084
|
+
$limit: 1
|
|
8085
|
+
}
|
|
8086
|
+
})).length > 0;
|
|
8087
|
+
}
|
|
8088
|
+
/**
|
|
8089
|
+
* Application-level cascade, then the delete. `translate` maps `filter` to
|
|
8090
|
+
* the adapter's physical filter (identity for an already-physical one).
|
|
8091
|
+
*/
|
|
8092
|
+
async _cascadeDelete(filter, translate) {
|
|
8093
|
+
const pin = await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, translate, this.adapter);
|
|
8094
|
+
if (pin) {
|
|
8095
|
+
let deletedCount = 0;
|
|
8096
|
+
for (const batch of pin) deletedCount += (await this.adapter.deleteMany(batch)).deletedCount;
|
|
8097
|
+
return { deletedCount };
|
|
8098
|
+
}
|
|
8099
|
+
return this.adapter.deleteMany(translate(filter));
|
|
8100
|
+
}
|
|
8101
|
+
/**
|
|
8102
|
+
* Validates `opts` of a bounded `updateMany` / `deleteMany` and returns the
|
|
8103
|
+
* translated read of the rows it picks (filter, order with the primary-key
|
|
8104
|
+
* tie-breaker, `limit`, primary-key projection), or `undefined` when the
|
|
8105
|
+
* write is unbounded.
|
|
8106
|
+
*/
|
|
8107
|
+
_writeBound(filter, opts) {
|
|
8108
|
+
if (opts?.limit === void 0) {
|
|
8109
|
+
if (opts?.sort !== void 0) throw invalidQuery("sort", "sort applies to a bounded write only — set limit");
|
|
8110
|
+
return;
|
|
8111
|
+
}
|
|
8112
|
+
const limit = opts.limit;
|
|
8113
|
+
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1) throw invalidQuery("limit", `limit must be a positive integer, got ${String(limit)}`);
|
|
8114
|
+
const pk = this._meta.primaryKeys;
|
|
8115
|
+
if (pk.length === 0) throw invalidQuery("limit", "a bounded write needs a primary key to order and pin its rows");
|
|
8116
|
+
let sort;
|
|
8117
|
+
if (opts.sort !== void 0 && Object.keys(opts.sort).length > 0) {
|
|
8118
|
+
sort = {};
|
|
8119
|
+
for (const [field, dir] of Object.entries(opts.sort)) {
|
|
8120
|
+
if (dir !== 1 && dir !== -1) throw invalidQuery(`sort.${field}`, "sort direction must be 1 or -1");
|
|
8121
|
+
sort[field] = dir;
|
|
8122
|
+
}
|
|
8123
|
+
guardPaths(this._meta, this.adapter, { controls: { $sort: sort } });
|
|
8124
|
+
} else sort = Object.fromEntries(pk.map((field) => [field, 1]));
|
|
8125
|
+
const query = this._fieldMapper.translateQuery(this._withStableSort({
|
|
8126
|
+
filter,
|
|
8127
|
+
controls: {
|
|
8128
|
+
$sort: sort,
|
|
8129
|
+
$limit: limit,
|
|
8130
|
+
$select: [...pk]
|
|
8131
|
+
}
|
|
8132
|
+
}), this._meta);
|
|
8133
|
+
const writeLimit = {
|
|
8134
|
+
limit,
|
|
8135
|
+
sort: query.controls.$sort
|
|
8136
|
+
};
|
|
8137
|
+
if (query.controls.$nulls) writeLimit.nulls = query.controls.$nulls;
|
|
8138
|
+
return {
|
|
8139
|
+
query,
|
|
8140
|
+
writeLimit
|
|
8141
|
+
};
|
|
8142
|
+
}
|
|
8143
|
+
/** `updateMany` / `replaceMany` write the table's own fields only. */
|
|
8144
|
+
_rejectNavWrite(data, method) {
|
|
8145
|
+
for (const navField of this._meta.navFields) if (data[navField] !== void 0) throw invalidQuery(navField, `${method} writes the table's own fields only — write relations through the keyed methods (updateOne / bulkUpdate / replaceOne / bulkReplace)`);
|
|
8146
|
+
}
|
|
8147
|
+
/**
|
|
8148
|
+
* A logical patch → the adapter's physical `{ update, ops }`: nested
|
|
8149
|
+
* merge-strategy objects decomposed into dot-paths (so nested field ops are
|
|
8150
|
+
* caught), field ops separated, keys translated.
|
|
8151
|
+
*/
|
|
8152
|
+
_translatePatch(data) {
|
|
8153
|
+
const decomposed = decomposePatch(data, this);
|
|
8154
|
+
const ops = separateFieldOps(decomposed);
|
|
8155
|
+
return {
|
|
8156
|
+
update: this._fieldMapper.translatePatchKeys(decomposed, this._meta),
|
|
8157
|
+
ops: ops ? this._fieldMapper.translateOpsKeys(ops, this._meta) : void 0
|
|
8158
|
+
};
|
|
7575
8159
|
}
|
|
7576
8160
|
/**
|
|
7577
8161
|
* Synchronizes indexes between Atscript definitions and the database.
|
|
7578
8162
|
*/
|
|
7579
8163
|
async syncIndexes() {
|
|
7580
8164
|
this._ensureBuilt();
|
|
7581
|
-
|
|
8165
|
+
await this.adapter.syncIndexes();
|
|
7582
8166
|
}
|
|
7583
8167
|
/**
|
|
7584
8168
|
* Ensures the table/collection exists in the database.
|
|
@@ -7809,10 +8393,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7809
8393
|
if (!(keys.length === fk.targetFields.length && fk.targetFields.every((f) => keys.includes(f) && target[f] != null))) continue;
|
|
7810
8394
|
fk.localFields.forEach((local, i) => {
|
|
7811
8395
|
const value = target[fk.targetFields[i]];
|
|
7812
|
-
if (item[local] !== void 0 && item[local] !== null && !sameKey(item[local], value)) throw
|
|
7813
|
-
path: navField,
|
|
7814
|
-
message: `"${navField}" and "${local}" name different parents`
|
|
7815
|
-
}]);
|
|
8396
|
+
if (item[local] !== void 0 && item[local] !== null && !sameKey(item[local], value)) throw invalidQuery(navField, `"${navField}" and "${local}" name different parents`);
|
|
7816
8397
|
item[local] = value;
|
|
7817
8398
|
});
|
|
7818
8399
|
delete item[navField];
|
|
@@ -7823,10 +8404,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7823
8404
|
_rejectNestedToInIgnoreMode(items) {
|
|
7824
8405
|
for (const [navField, relation] of this._meta.relations) {
|
|
7825
8406
|
if (relation.direction !== "to") continue;
|
|
7826
|
-
if (items.some((item) => item[navField] !== void 0 && item[navField] !== null)) throw
|
|
7827
|
-
path: navField,
|
|
7828
|
-
message: "onConflict \"ignore\" cannot create a related parent record — insert it first or reference it by key"
|
|
7829
|
-
}]);
|
|
8407
|
+
if (items.some((item) => item[navField] !== void 0 && item[navField] !== null)) throw invalidQuery(navField, "onConflict \"ignore\" cannot create a related parent record — insert it first or reference it by key");
|
|
7830
8408
|
}
|
|
7831
8409
|
}
|
|
7832
8410
|
/**
|
|
@@ -7852,10 +8430,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7852
8430
|
send.push(i);
|
|
7853
8431
|
}
|
|
7854
8432
|
const slots = send.length ? await this.adapter.insertManyIgnore(send.map((i) => prepared[i]), opts) : [];
|
|
7855
|
-
if (slots.length !== send.length) throw
|
|
7856
|
-
path: "",
|
|
7857
|
-
message: "Adapter insertManyIgnore must return one slot per input row"
|
|
7858
|
-
}]);
|
|
8433
|
+
if (slots.length !== send.length) throw invalidQuery("", "Adapter insertManyIgnore must return one slot per input row");
|
|
7859
8434
|
const insertedIds = [];
|
|
7860
8435
|
const inserted = [];
|
|
7861
8436
|
send.forEach((rowIndex, k) => {
|
|
@@ -7880,20 +8455,14 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7880
8455
|
*/
|
|
7881
8456
|
_insertedPkFilters(items, prepared, insertedIds) {
|
|
7882
8457
|
const pkFields = this.primaryKeys;
|
|
7883
|
-
if (pkFields.length === 0 && items.length > 0) throw
|
|
7884
|
-
path: "",
|
|
7885
|
-
message: "Write check requires a primary key on the table"
|
|
7886
|
-
}]);
|
|
8458
|
+
if (pkFields.length === 0 && items.length > 0) throw invalidQuery("", "Write check requires a primary key on the table");
|
|
7887
8459
|
return items.map((item, i) => {
|
|
7888
8460
|
const key = {};
|
|
7889
8461
|
for (const field of pkFields) {
|
|
7890
8462
|
let value = item[field];
|
|
7891
8463
|
if (value === void 0) value = prepared[i]?.[this._meta.columnMap.get(field) ?? field];
|
|
7892
8464
|
if (value === void 0 && pkFields.length === 1) value = insertedIds[i];
|
|
7893
|
-
if (value === void 0 || value === null) throw
|
|
7894
|
-
path: field,
|
|
7895
|
-
message: `Write check: cannot resolve the primary key of inserted row [${i}]`
|
|
7896
|
-
}]);
|
|
8465
|
+
if (value === void 0 || value === null) throw invalidQuery(field, `Write check: cannot resolve the primary key of inserted row [${i}]`);
|
|
7897
8466
|
key[field] = value;
|
|
7898
8467
|
}
|
|
7899
8468
|
return this._pkFilterFrom(key);
|
|
@@ -7962,14 +8531,20 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
7962
8531
|
* them once the patch is known to write something.
|
|
7963
8532
|
*/
|
|
7964
8533
|
_stampOnUpdate(data, now, topLevel) {
|
|
8534
|
+
for (const field of this._meta.onUpdateInc) delete data[field];
|
|
7965
8535
|
for (const field of this._meta.onUpdateNow) if (!field.includes(".")) if (topLevel) data[field] = now;
|
|
7966
8536
|
else delete data[field];
|
|
7967
8537
|
else if (this._nestedDefaultFillable(field)) stampNested(data, field.split("."), 0, now);
|
|
7968
8538
|
}
|
|
7969
|
-
/**
|
|
7970
|
-
|
|
7971
|
-
|
|
8539
|
+
/**
|
|
8540
|
+
* Sets the top-level `@db.onUpdate.now` columns of a non-empty patch to
|
|
8541
|
+
* `now` and increments its `@db.onUpdate.inc` counters (`$inc: 1`).
|
|
8542
|
+
* `force`: stamp an empty patch too (a touch writes the row).
|
|
8543
|
+
*/
|
|
8544
|
+
_stampTopLevel(data, now, force = false) {
|
|
8545
|
+
if (!force && isEmptyObject(data)) return;
|
|
7972
8546
|
for (const field of this._meta.onUpdateNow) if (!field.includes(".")) data[field] = now;
|
|
8547
|
+
for (const field of this._meta.onUpdateInc) data[field] = { $inc: 1 };
|
|
7973
8548
|
}
|
|
7974
8549
|
/** {@link _nestedDefaultFillable} per nested default path. */
|
|
7975
8550
|
_fillableDefaults;
|
|
@@ -8852,7 +9427,7 @@ var AtscriptDbView = class extends AtscriptDbReadable {
|
|
|
8852
9427
|
continue;
|
|
8853
9428
|
}
|
|
8854
9429
|
if (source.flattened) fail(fieldName, "source is a flattened object — remove @db.json from the view field");
|
|
8855
|
-
const mapping = this._leafMapping(fieldName, ownColumn ?? fieldName, sourceTable, source, joinNullable);
|
|
9430
|
+
const mapping = this._leafMapping(fieldName, ownColumn ?? fieldName, sourceTable, source, joinNullable, fieldType.metadata.has("db.json"));
|
|
8856
9431
|
mappings.push(agg ? {
|
|
8857
9432
|
...mapping,
|
|
8858
9433
|
...agg
|
|
@@ -8890,7 +9465,7 @@ var AtscriptDbView = class extends AtscriptDbReadable {
|
|
|
8890
9465
|
for (const m of computed) visit(m, []);
|
|
8891
9466
|
}
|
|
8892
9467
|
/** One view column over one physical source (a column or a JSON leaf). */
|
|
8893
|
-
_leafMapping(viewPath, viewColumn, sourceTable, source, joinNullable) {
|
|
9468
|
+
_leafMapping(viewPath, viewColumn, sourceTable, source, joinNullable, viewJson = false) {
|
|
8894
9469
|
const mapping = {
|
|
8895
9470
|
viewColumn,
|
|
8896
9471
|
viewPath,
|
|
@@ -8904,7 +9479,10 @@ var AtscriptDbView = class extends AtscriptDbReadable {
|
|
|
8904
9479
|
path: source.jsonPath,
|
|
8905
9480
|
type: source.designType
|
|
8906
9481
|
};
|
|
8907
|
-
}
|
|
9482
|
+
} else if (source.jsonColumn && !viewJson && isJsonLeafType(source.designType)) mapping.json = {
|
|
9483
|
+
path: [],
|
|
9484
|
+
type: source.designType
|
|
9485
|
+
};
|
|
8908
9486
|
return mapping;
|
|
8909
9487
|
}
|
|
8910
9488
|
};
|
|
@@ -8921,62 +9499,6 @@ function isAtscriptDbView(readable) {
|
|
|
8921
9499
|
return readable.isView;
|
|
8922
9500
|
}
|
|
8923
9501
|
//#endregion
|
|
8924
|
-
//#region src/schema/fk-diff.ts
|
|
8925
|
-
/** Canonical key for an FK: sorted local field names, comma-joined. */
|
|
8926
|
-
function fkKey(fields) {
|
|
8927
|
-
return [...fields].toSorted().join(",");
|
|
8928
|
-
}
|
|
8929
|
-
/**
|
|
8930
|
-
* Physical local / target column names of a desired FK (`@db.column` renames
|
|
8931
|
-
* applied) — what DDL, constraint sync, the FK diff and the snapshot compare.
|
|
8932
|
-
*/
|
|
8933
|
-
function fkColumns(fk) {
|
|
8934
|
-
return {
|
|
8935
|
-
fields: fk.physicalFields ?? fk.fields,
|
|
8936
|
-
targetFields: fk.physicalTargetFields ?? fk.targetFields
|
|
8937
|
-
};
|
|
8938
|
-
}
|
|
8939
|
-
/**
|
|
8940
|
-
* Compares desired FK constraints against stored snapshot to detect
|
|
8941
|
-
* additions, removals, and property changes (target table, target fields,
|
|
8942
|
-
* onDelete, onUpdate).
|
|
8943
|
-
*/
|
|
8944
|
-
function computeForeignKeyDiff(desired, existingSnapshot) {
|
|
8945
|
-
const added = [];
|
|
8946
|
-
const removed = [];
|
|
8947
|
-
const changed = [];
|
|
8948
|
-
const existingByKey = /* @__PURE__ */ new Map();
|
|
8949
|
-
for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
|
|
8950
|
-
const desiredKeys = /* @__PURE__ */ new Set();
|
|
8951
|
-
for (const fk of desired.values()) {
|
|
8952
|
-
const key = fkKey(fkColumns(fk).fields);
|
|
8953
|
-
desiredKeys.add(key);
|
|
8954
|
-
const existing = existingByKey.get(key);
|
|
8955
|
-
if (!existing) added.push(fk);
|
|
8956
|
-
else if (fkPropertiesDiffer(fk, existing)) changed.push({
|
|
8957
|
-
desired: fk,
|
|
8958
|
-
existing
|
|
8959
|
-
});
|
|
8960
|
-
}
|
|
8961
|
-
for (const [key, fk] of existingByKey) if (!desiredKeys.has(key)) removed.push(fk);
|
|
8962
|
-
return {
|
|
8963
|
-
added,
|
|
8964
|
-
removed,
|
|
8965
|
-
changed
|
|
8966
|
-
};
|
|
8967
|
-
}
|
|
8968
|
-
/** Whether the FK diff contains any changes. */
|
|
8969
|
-
function hasForeignKeyChanges(diff) {
|
|
8970
|
-
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
8971
|
-
}
|
|
8972
|
-
function fkPropertiesDiffer(desired, existing) {
|
|
8973
|
-
if (desired.targetTable !== existing.targetTable) return true;
|
|
8974
|
-
if (fkKey(fkColumns(desired).targetFields) !== fkKey(existing.targetFields)) return true;
|
|
8975
|
-
if ((desired.onDelete ?? void 0) !== (existing.onDelete ?? void 0)) return true;
|
|
8976
|
-
if ((desired.onUpdate ?? void 0) !== (existing.onUpdate ?? void 0)) return true;
|
|
8977
|
-
return false;
|
|
8978
|
-
}
|
|
8979
|
-
//#endregion
|
|
8980
9502
|
//#region src/schema/schema-hash.ts
|
|
8981
9503
|
/**
|
|
8982
9504
|
* The physical sources a stored view snapshot reads: its entry table and the
|
|
@@ -9244,6 +9766,90 @@ function isColumnTypeChanged(existingType, expectedType) {
|
|
|
9244
9766
|
return existingType.toUpperCase() !== expectedType.toUpperCase();
|
|
9245
9767
|
}
|
|
9246
9768
|
/**
|
|
9769
|
+
* Whether a live column default (as the adapter's introspection normalized
|
|
9770
|
+
* it) and a model default (`serializeDefaultValue`) denote the same value for
|
|
9771
|
+
* `field` — compared in a canonical, type-aware form, because the DDL writes a
|
|
9772
|
+
* literal that differs from the annotation text and the engine reformats it
|
|
9773
|
+
* again: booleans as `1` / `0` (`true` / `1` → `1`), numbers numerically
|
|
9774
|
+
* (`1.0` = `1`, a DECIMAL's `0.00` = `0`), JSON-stored values as parsed,
|
|
9775
|
+
* key-sorted JSON (`{"a": 1}` = `{"a":1}`). With a column type `liveType`
|
|
9776
|
+
* of a fixed scale (`DECIMAL(10,2)`, `NUMERIC(8,3)`) — the type the column
|
|
9777
|
+
* has once synced, i.e. the model's — a model value is also the value the
|
|
9778
|
+
* column stores for it (`2.345` = `2.35`).
|
|
9779
|
+
* Function defaults (`fn:now`, `fn:uuid`, …) and every other value compare as
|
|
9780
|
+
* strings. Exported for adapters that must agree with schema sync.
|
|
9781
|
+
* @since 0.1.162
|
|
9782
|
+
*/
|
|
9783
|
+
function sameDefaultValue(field, live, desired, liveType) {
|
|
9784
|
+
if (live === desired) return true;
|
|
9785
|
+
if (live === void 0 || desired === void 0) return false;
|
|
9786
|
+
if (live.startsWith("fn:") || desired.startsWith("fn:")) return false;
|
|
9787
|
+
if (isJsonDefault(field)) {
|
|
9788
|
+
const a = canonicalJson(live);
|
|
9789
|
+
return a !== void 0 && a === canonicalJson(desired);
|
|
9790
|
+
}
|
|
9791
|
+
switch (field.designType) {
|
|
9792
|
+
case "boolean": {
|
|
9793
|
+
const a = canonicalBoolean(live);
|
|
9794
|
+
return a !== void 0 && a === canonicalBoolean(desired);
|
|
9795
|
+
}
|
|
9796
|
+
case "number":
|
|
9797
|
+
case "integer":
|
|
9798
|
+
case "decimal": return sameNumber(live, desired, liveType);
|
|
9799
|
+
default: return false;
|
|
9800
|
+
}
|
|
9801
|
+
}
|
|
9802
|
+
function isJsonDefault(field) {
|
|
9803
|
+
return field.storage === "json" || field.designType === "json" || field.designType === "array" || field.designType === "object";
|
|
9804
|
+
}
|
|
9805
|
+
function canonicalBoolean(value) {
|
|
9806
|
+
const v = value.trim().toLowerCase();
|
|
9807
|
+
if (v === "true" || v === "1") return "1";
|
|
9808
|
+
if (v === "false" || v === "0") return "0";
|
|
9809
|
+
}
|
|
9810
|
+
/** Parsed JSON re-serialized with object keys sorted; `undefined` when not JSON. */
|
|
9811
|
+
function canonicalJson(value) {
|
|
9812
|
+
try {
|
|
9813
|
+
return stableJson(JSON.parse(value));
|
|
9814
|
+
} catch {
|
|
9815
|
+
return;
|
|
9816
|
+
}
|
|
9817
|
+
}
|
|
9818
|
+
const DECIMAL_RE = /^([+-]?)(\d*)(?:\.(\d*))?$/;
|
|
9819
|
+
const SCALED_TYPE_RE = /^(?:DECIMAL|NUMERIC|DEC|FIXED)\s*\(\s*\d+\s*,\s*(\d+)\s*\)/i;
|
|
9820
|
+
/**
|
|
9821
|
+
* Numeric equality of two default literals — or, on a column of a fixed
|
|
9822
|
+
* scale (`liveType` `DECIMAL(p,s)` / `NUMERIC(p,s)`), of the live value and
|
|
9823
|
+
* the model value rounded half-up to that scale, as the engine stores it.
|
|
9824
|
+
*/
|
|
9825
|
+
function sameNumber(live, desired, liveType) {
|
|
9826
|
+
const l = live.trim();
|
|
9827
|
+
const d = desired.trim();
|
|
9828
|
+
if (l === "" || d === "") return false;
|
|
9829
|
+
const a = Number(l);
|
|
9830
|
+
const b = Number(d);
|
|
9831
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
|
|
9832
|
+
if (a === b) return true;
|
|
9833
|
+
const scale = liveType ? SCALED_TYPE_RE.exec(liveType.trim())?.[1] : void 0;
|
|
9834
|
+
if (scale === void 0) return false;
|
|
9835
|
+
const rounded = roundDecimal(d, Number(scale));
|
|
9836
|
+
return rounded !== void 0 && Number(rounded) === a;
|
|
9837
|
+
}
|
|
9838
|
+
/** `value` (a plain decimal literal) rounded half-up to `scale` fraction digits, exactly. */
|
|
9839
|
+
function roundDecimal(value, scale) {
|
|
9840
|
+
const m = DECIMAL_RE.exec(value);
|
|
9841
|
+
if (!m) return;
|
|
9842
|
+
const [, sign, int, frac = ""] = m;
|
|
9843
|
+
if (frac.length <= scale) return value;
|
|
9844
|
+
const digits = `${int || "0"}${frac.slice(0, scale)}`;
|
|
9845
|
+
let n = BigInt(digits);
|
|
9846
|
+
if (frac.charCodeAt(scale) >= 53) n += 1n;
|
|
9847
|
+
const str = n.toString().padStart(scale + 1, "0");
|
|
9848
|
+
const head = str.slice(0, str.length - scale);
|
|
9849
|
+
const tail = str.slice(str.length - scale);
|
|
9850
|
+
return `${sign}${head}${scale > 0 ? `.${tail}` : ""}`;
|
|
9851
|
+
}
|
|
9852
|
+
/**
|
|
9247
9853
|
* Computes the difference between desired schema fields and existing database columns.
|
|
9248
9854
|
*
|
|
9249
9855
|
* @param desired - Field descriptors from the Atscript type (after flattening) —
|
|
@@ -9253,10 +9859,7 @@ function isColumnTypeChanged(existingType, expectedType) {
|
|
|
9253
9859
|
* Receives the full field meta (design type, annotations, PK status, etc.)
|
|
9254
9860
|
* so adapters can produce context-aware types (e.g., `VARCHAR(255)` from maxLength).
|
|
9255
9861
|
* Required for type change detection.
|
|
9256
|
-
* @param opts
|
|
9257
|
-
* derived column's expression is compared with (the engine normalizes
|
|
9258
|
-
* the expression it stores, so the live column cannot be). Without
|
|
9259
|
-
* one, only a kind or type drift is seen.
|
|
9862
|
+
* @param opts - See {@link TColumnDiffOptions}.
|
|
9260
9863
|
*/
|
|
9261
9864
|
function computeColumnDiff(desired, existing, typeMapper, opts) {
|
|
9262
9865
|
const existingByName = new Map(existing.map((c) => [c.name, c]));
|
|
@@ -9297,12 +9900,11 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
|
|
|
9297
9900
|
});
|
|
9298
9901
|
renamedOldNames.add(field.renamedFrom);
|
|
9299
9902
|
} else if (!checkDerived(field, existingCol, field.physicalName)) {
|
|
9300
|
-
|
|
9301
|
-
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
}
|
|
9903
|
+
const mappedType = typeMapper?.(field);
|
|
9904
|
+
if (mappedType !== void 0 && isColumnTypeChanged(existingCol.type, mappedType)) typeChanged.push({
|
|
9905
|
+
field,
|
|
9906
|
+
existingType: existingCol.type
|
|
9907
|
+
});
|
|
9306
9908
|
if (!field.isPrimaryKey && !existingCol.pk) {
|
|
9307
9909
|
const desiredNotNull = !field.optional;
|
|
9308
9910
|
if (existingCol.notnull !== desiredNotNull) nullableChanged.push({
|
|
@@ -9310,12 +9912,13 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
|
|
|
9310
9912
|
wasNullable: !existingCol.notnull
|
|
9311
9913
|
});
|
|
9312
9914
|
}
|
|
9915
|
+
const liveDefault = existingCol.dflt_value;
|
|
9313
9916
|
const desiredDefault = serializeDefaultValue(field.defaultValue);
|
|
9314
9917
|
const fn = field.defaultValue?.kind === "fn" ? field.defaultValue.fn : void 0;
|
|
9315
9918
|
const missingNative = fn !== void 0 && fn !== "increment" && opts?.nativeDefaultFns?.has(fn) === true;
|
|
9316
|
-
if ((
|
|
9919
|
+
if ((liveDefault !== void 0 || missingNative) && liveDefault !== desiredDefault && !sameDefaultValue(field, liveDefault, desiredDefault, mappedType ?? existingCol.type) && !(liveDefault !== void 0 && desiredDefault !== void 0 && opts?.reportedDefault?.(desiredDefault, field) === liveDefault)) defaultChanged.push({
|
|
9317
9920
|
field,
|
|
9318
|
-
oldDefault:
|
|
9921
|
+
oldDefault: liveDefault,
|
|
9319
9922
|
newDefault: desiredDefault
|
|
9320
9923
|
});
|
|
9321
9924
|
}
|
|
@@ -9350,4 +9953,4 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
|
|
|
9350
9953
|
return diff;
|
|
9351
9954
|
}
|
|
9352
9955
|
//#endregion
|
|
9353
|
-
export {
|
|
9956
|
+
export { narrowerFilterOps as $, hasForeignKeyChanges as A, acceptedOperatorsHint as B, ALL_VIEW_CAPABILITIES as C, computeForeignKeyDiff as D, createFailureCollector as E, geoIndexNotFoundMessage as F, classifyQueryPath as G, bucketSourceVerdict as H, searchIndexNotFoundMessage as I, guardAggregate as J, collectQueryPaths as K, vectorIndexNotFoundMessage as L, NativeIntegrity as M, AtscriptDbReadable as N, fkColumns as O, resolveDesignType as P, guardQuery as Q, ADAPTER_FILTER_REASON as R, ALL_ISOLATION_LEVELS as S, NoopLogger as St, createChangeRecorder as T, canFilterLeaf as U, assertGeoPoint as V, checkHavingKeys as W, guardPath as X, guardFilter as Y, guardPaths as Z, isVersionExemptPatch as _, isBucketableField as _t, computeSchemaHash as a, UniquSelect as at, ApplicationIntegrity as b, normalizeComputedSelect as bt, computeViewSnapshot as c, defaultFulltextIndex as ct, snapshotToExistingTableOptions as d, splitFulltextFields as dt, sortFieldNames as et, viewSnapshotSources as f, TableMetadata as ft, AtscriptDbTable as g, aliasTargetOf as gt, isViewType as h, relationalColumnName as ht, canonicalizeQueryNode as i, FieldMappingStrategy as it, IntegrityStrategy as j, fkKey as k, serializeDefaultValue as l, describeFulltext as lt, isAtscriptDbView as m, isGeoPointType as mt, isColumnTypeChanged as n, RelationalFieldMapper as nt, computeTableHash as o, numericOperandProblem as ot, AtscriptDbView as p, isGeoIndexableType as pt, groupSourceVerdict as q, sameDefaultValue as r, DocumentFieldMapper as rt, computeTableSnapshot as s, INTEGER_REGEX_OP as st, computeColumnDiff as t, unsupportedOperatorMessage as tt, snapshotToExistingColumns as u, searchTermInteger as ut, assertNoVersionWrites as v, isJsonValueField as vt, BaseDbAdapter as w, ALL_BUCKET_UNITS as x, resolveCalendarBuckets as xt, decomposePatch as y, jsonValueAncestor as yt, ENCRYPTED_REASON as z };
|