@atscript/db-mongo 0.1.95 → 0.1.97
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/index.cjs +83 -5
- package/dist/index.d.cts +8 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +83 -5
- package/package.json +7 -7
package/dist/index.cjs
CHANGED
|
@@ -1064,12 +1064,14 @@ async function syncIndexesImpl(host) {
|
|
|
1064
1064
|
mongoType = index.type;
|
|
1065
1065
|
for (const f of index.fields) fields[f.name] = 1;
|
|
1066
1066
|
}
|
|
1067
|
+
const partialFilterExpression = index.type === "unique" ? buildPresentOnlyFilter(index.fields) : void 0;
|
|
1067
1068
|
allIndexes.set(key, {
|
|
1068
1069
|
key,
|
|
1069
1070
|
name: index.name,
|
|
1070
1071
|
type: mongoType,
|
|
1071
1072
|
fields,
|
|
1072
|
-
weights
|
|
1073
|
+
weights,
|
|
1074
|
+
...partialFilterExpression ? { partialFilterExpression } : {}
|
|
1073
1075
|
});
|
|
1074
1076
|
}
|
|
1075
1077
|
for (const [key, index] of host._mongoIndexes.entries()) if (index.type === "text") {
|
|
@@ -1088,13 +1090,17 @@ async function syncIndexesImpl(host) {
|
|
|
1088
1090
|
switch (local.type) {
|
|
1089
1091
|
case "plain":
|
|
1090
1092
|
case "unique":
|
|
1091
|
-
case "text":
|
|
1092
|
-
|
|
1093
|
+
case "text": {
|
|
1094
|
+
const fieldsMatch = local.type === "text" || objMatch(local.fields, remote.key);
|
|
1095
|
+
const weightsMatch = objMatch(local.weights || {}, remote.weights || {});
|
|
1096
|
+
const optionsMatch = local.type === "text" || local.type === "unique" === (remote.unique === true) && partialFilterEqual(local.partialFilterExpression, remote.partialFilterExpression);
|
|
1097
|
+
if (fieldsMatch && weightsMatch && optionsMatch) indexesToCreate.delete(remote.name);
|
|
1093
1098
|
else {
|
|
1094
1099
|
host._log("dropIndex", remote.name);
|
|
1095
1100
|
await host.collection.dropIndex(remote.name);
|
|
1096
1101
|
}
|
|
1097
1102
|
break;
|
|
1103
|
+
}
|
|
1098
1104
|
default:
|
|
1099
1105
|
}
|
|
1100
1106
|
} else {
|
|
@@ -1110,10 +1116,11 @@ async function syncIndexesImpl(host) {
|
|
|
1110
1116
|
break;
|
|
1111
1117
|
case "unique":
|
|
1112
1118
|
if (!indexesToCreate.has(key)) continue;
|
|
1113
|
-
host._log("createIndex (unique)", key, value.fields);
|
|
1119
|
+
host._log("createIndex (unique)", key, value.fields, value.partialFilterExpression);
|
|
1114
1120
|
await host.collection.createIndex(value.fields, {
|
|
1115
1121
|
name: key,
|
|
1116
|
-
unique: true
|
|
1122
|
+
unique: true,
|
|
1123
|
+
...value.partialFilterExpression ? { partialFilterExpression: value.partialFilterExpression } : {}
|
|
1117
1124
|
});
|
|
1118
1125
|
break;
|
|
1119
1126
|
case "text":
|
|
@@ -1173,6 +1180,77 @@ async function syncIndexesImpl(host) {
|
|
|
1173
1180
|
}
|
|
1174
1181
|
} catch {}
|
|
1175
1182
|
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Maps an engine-agnostic design type to the MongoDB BSON `$type` alias(es)
|
|
1185
|
+
* meaning "a present value of this type". Using `$type` (rather than a bare
|
|
1186
|
+
* `sparse: true` or `$exists: true`) excludes BOTH absent and explicit-null
|
|
1187
|
+
* values, so a row whose optional field was written as `null` — e.g. by a
|
|
1188
|
+
* replace-strategy patch — is still tolerated by the unique constraint.
|
|
1189
|
+
*/
|
|
1190
|
+
function bsonPresentTypes(designType) {
|
|
1191
|
+
switch (designType) {
|
|
1192
|
+
case "string": return "string";
|
|
1193
|
+
case "objectId": return ["objectId", "string"];
|
|
1194
|
+
case "number":
|
|
1195
|
+
case "decimal": return "number";
|
|
1196
|
+
case "boolean": return "bool";
|
|
1197
|
+
default: return [
|
|
1198
|
+
"double",
|
|
1199
|
+
"string",
|
|
1200
|
+
"object",
|
|
1201
|
+
"array",
|
|
1202
|
+
"binData",
|
|
1203
|
+
"objectId",
|
|
1204
|
+
"bool",
|
|
1205
|
+
"date",
|
|
1206
|
+
"regex",
|
|
1207
|
+
"int",
|
|
1208
|
+
"timestamp",
|
|
1209
|
+
"long",
|
|
1210
|
+
"decimal"
|
|
1211
|
+
];
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Builds a `partialFilterExpression` restricting a unique index to rows where
|
|
1216
|
+
* every OPTIONAL field is present. Returns undefined when no field is optional
|
|
1217
|
+
* (a plain unique index — no nulls possible — needs no filter).
|
|
1218
|
+
*
|
|
1219
|
+
* Filtering on the optional fields (not the required ones) matches SQL's NULLS
|
|
1220
|
+
* DISTINCT: a composite unique row is exempt as soon as any nullable column is
|
|
1221
|
+
* null, so many value-less rows coexist while fully populated rows stay unique.
|
|
1222
|
+
*/
|
|
1223
|
+
function buildPresentOnlyFilter(indexFields) {
|
|
1224
|
+
const optional = indexFields.filter((f) => f.optional);
|
|
1225
|
+
if (optional.length === 0) return;
|
|
1226
|
+
const clauses = optional.map((f) => ({
|
|
1227
|
+
name: f.name,
|
|
1228
|
+
clause: { [f.name]: { $type: bsonPresentTypes(f.designType) } }
|
|
1229
|
+
})).toSorted((a, b) => a.name.localeCompare(b.name)).map((c) => c.clause);
|
|
1230
|
+
return clauses.length === 1 ? clauses[0] : { $and: clauses };
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Deep structural equality for `partialFilterExpression` objects, used to detect
|
|
1234
|
+
* when a unique index's present-only filter has changed. Object keys compare
|
|
1235
|
+
* order-insensitively; arrays (`$and`, `$type` lists) are order-sensitive,
|
|
1236
|
+
* matching this module's deterministic emission. A missing filter (undefined)
|
|
1237
|
+
* and an explicit `null` both mean "no filter" and compare equal.
|
|
1238
|
+
*/
|
|
1239
|
+
function partialFilterEqual(a, b) {
|
|
1240
|
+
if (a === b) return true;
|
|
1241
|
+
if (a == null || b == null) return a == null && b == null;
|
|
1242
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
1243
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
1244
|
+
return a.every((item, i) => partialFilterEqual(item, b[i]));
|
|
1245
|
+
}
|
|
1246
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
1247
|
+
const ka = Object.keys(a);
|
|
1248
|
+
const kb = Object.keys(b);
|
|
1249
|
+
if (ka.length !== kb.length) return false;
|
|
1250
|
+
return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && partialFilterEqual(a[k], b[k]));
|
|
1251
|
+
}
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1176
1254
|
function objMatch(o1, o2) {
|
|
1177
1255
|
const keys1 = Object.keys(o1);
|
|
1178
1256
|
const keys2 = Object.keys(o2);
|
package/dist/index.d.cts
CHANGED
|
@@ -151,6 +151,14 @@ interface TPlainIndex {
|
|
|
151
151
|
type: "plain" | "unique" | "text";
|
|
152
152
|
fields: Record<string, 1 | "text">;
|
|
153
153
|
weights: Record<string, number>;
|
|
154
|
+
/**
|
|
155
|
+
* For "present-only" unique indexes on optional fields: a MongoDB
|
|
156
|
+
* `partialFilterExpression` restricting the index to rows where the optional
|
|
157
|
+
* field(s) are present. This lets multiple value-less rows coexist (matching
|
|
158
|
+
* SQL's `NULLS DISTINCT`) while present values stay unique. Absent for plain
|
|
159
|
+
* unique indexes (all fields required) and non-unique indexes.
|
|
160
|
+
*/
|
|
161
|
+
partialFilterExpression?: Record<string, unknown>;
|
|
154
162
|
}
|
|
155
163
|
interface TSearchIndex {
|
|
156
164
|
key: string;
|
package/dist/index.d.mts
CHANGED
|
@@ -151,6 +151,14 @@ interface TPlainIndex {
|
|
|
151
151
|
type: "plain" | "unique" | "text";
|
|
152
152
|
fields: Record<string, 1 | "text">;
|
|
153
153
|
weights: Record<string, number>;
|
|
154
|
+
/**
|
|
155
|
+
* For "present-only" unique indexes on optional fields: a MongoDB
|
|
156
|
+
* `partialFilterExpression` restricting the index to rows where the optional
|
|
157
|
+
* field(s) are present. This lets multiple value-less rows coexist (matching
|
|
158
|
+
* SQL's `NULLS DISTINCT`) while present values stay unique. Absent for plain
|
|
159
|
+
* unique indexes (all fields required) and non-unique indexes.
|
|
160
|
+
*/
|
|
161
|
+
partialFilterExpression?: Record<string, unknown>;
|
|
154
162
|
}
|
|
155
163
|
interface TSearchIndex {
|
|
156
164
|
key: string;
|
package/dist/index.mjs
CHANGED
|
@@ -1063,12 +1063,14 @@ async function syncIndexesImpl(host) {
|
|
|
1063
1063
|
mongoType = index.type;
|
|
1064
1064
|
for (const f of index.fields) fields[f.name] = 1;
|
|
1065
1065
|
}
|
|
1066
|
+
const partialFilterExpression = index.type === "unique" ? buildPresentOnlyFilter(index.fields) : void 0;
|
|
1066
1067
|
allIndexes.set(key, {
|
|
1067
1068
|
key,
|
|
1068
1069
|
name: index.name,
|
|
1069
1070
|
type: mongoType,
|
|
1070
1071
|
fields,
|
|
1071
|
-
weights
|
|
1072
|
+
weights,
|
|
1073
|
+
...partialFilterExpression ? { partialFilterExpression } : {}
|
|
1072
1074
|
});
|
|
1073
1075
|
}
|
|
1074
1076
|
for (const [key, index] of host._mongoIndexes.entries()) if (index.type === "text") {
|
|
@@ -1087,13 +1089,17 @@ async function syncIndexesImpl(host) {
|
|
|
1087
1089
|
switch (local.type) {
|
|
1088
1090
|
case "plain":
|
|
1089
1091
|
case "unique":
|
|
1090
|
-
case "text":
|
|
1091
|
-
|
|
1092
|
+
case "text": {
|
|
1093
|
+
const fieldsMatch = local.type === "text" || objMatch(local.fields, remote.key);
|
|
1094
|
+
const weightsMatch = objMatch(local.weights || {}, remote.weights || {});
|
|
1095
|
+
const optionsMatch = local.type === "text" || local.type === "unique" === (remote.unique === true) && partialFilterEqual(local.partialFilterExpression, remote.partialFilterExpression);
|
|
1096
|
+
if (fieldsMatch && weightsMatch && optionsMatch) indexesToCreate.delete(remote.name);
|
|
1092
1097
|
else {
|
|
1093
1098
|
host._log("dropIndex", remote.name);
|
|
1094
1099
|
await host.collection.dropIndex(remote.name);
|
|
1095
1100
|
}
|
|
1096
1101
|
break;
|
|
1102
|
+
}
|
|
1097
1103
|
default:
|
|
1098
1104
|
}
|
|
1099
1105
|
} else {
|
|
@@ -1109,10 +1115,11 @@ async function syncIndexesImpl(host) {
|
|
|
1109
1115
|
break;
|
|
1110
1116
|
case "unique":
|
|
1111
1117
|
if (!indexesToCreate.has(key)) continue;
|
|
1112
|
-
host._log("createIndex (unique)", key, value.fields);
|
|
1118
|
+
host._log("createIndex (unique)", key, value.fields, value.partialFilterExpression);
|
|
1113
1119
|
await host.collection.createIndex(value.fields, {
|
|
1114
1120
|
name: key,
|
|
1115
|
-
unique: true
|
|
1121
|
+
unique: true,
|
|
1122
|
+
...value.partialFilterExpression ? { partialFilterExpression: value.partialFilterExpression } : {}
|
|
1116
1123
|
});
|
|
1117
1124
|
break;
|
|
1118
1125
|
case "text":
|
|
@@ -1172,6 +1179,77 @@ async function syncIndexesImpl(host) {
|
|
|
1172
1179
|
}
|
|
1173
1180
|
} catch {}
|
|
1174
1181
|
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Maps an engine-agnostic design type to the MongoDB BSON `$type` alias(es)
|
|
1184
|
+
* meaning "a present value of this type". Using `$type` (rather than a bare
|
|
1185
|
+
* `sparse: true` or `$exists: true`) excludes BOTH absent and explicit-null
|
|
1186
|
+
* values, so a row whose optional field was written as `null` — e.g. by a
|
|
1187
|
+
* replace-strategy patch — is still tolerated by the unique constraint.
|
|
1188
|
+
*/
|
|
1189
|
+
function bsonPresentTypes(designType) {
|
|
1190
|
+
switch (designType) {
|
|
1191
|
+
case "string": return "string";
|
|
1192
|
+
case "objectId": return ["objectId", "string"];
|
|
1193
|
+
case "number":
|
|
1194
|
+
case "decimal": return "number";
|
|
1195
|
+
case "boolean": return "bool";
|
|
1196
|
+
default: return [
|
|
1197
|
+
"double",
|
|
1198
|
+
"string",
|
|
1199
|
+
"object",
|
|
1200
|
+
"array",
|
|
1201
|
+
"binData",
|
|
1202
|
+
"objectId",
|
|
1203
|
+
"bool",
|
|
1204
|
+
"date",
|
|
1205
|
+
"regex",
|
|
1206
|
+
"int",
|
|
1207
|
+
"timestamp",
|
|
1208
|
+
"long",
|
|
1209
|
+
"decimal"
|
|
1210
|
+
];
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Builds a `partialFilterExpression` restricting a unique index to rows where
|
|
1215
|
+
* every OPTIONAL field is present. Returns undefined when no field is optional
|
|
1216
|
+
* (a plain unique index — no nulls possible — needs no filter).
|
|
1217
|
+
*
|
|
1218
|
+
* Filtering on the optional fields (not the required ones) matches SQL's NULLS
|
|
1219
|
+
* DISTINCT: a composite unique row is exempt as soon as any nullable column is
|
|
1220
|
+
* null, so many value-less rows coexist while fully populated rows stay unique.
|
|
1221
|
+
*/
|
|
1222
|
+
function buildPresentOnlyFilter(indexFields) {
|
|
1223
|
+
const optional = indexFields.filter((f) => f.optional);
|
|
1224
|
+
if (optional.length === 0) return;
|
|
1225
|
+
const clauses = optional.map((f) => ({
|
|
1226
|
+
name: f.name,
|
|
1227
|
+
clause: { [f.name]: { $type: bsonPresentTypes(f.designType) } }
|
|
1228
|
+
})).toSorted((a, b) => a.name.localeCompare(b.name)).map((c) => c.clause);
|
|
1229
|
+
return clauses.length === 1 ? clauses[0] : { $and: clauses };
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Deep structural equality for `partialFilterExpression` objects, used to detect
|
|
1233
|
+
* when a unique index's present-only filter has changed. Object keys compare
|
|
1234
|
+
* order-insensitively; arrays (`$and`, `$type` lists) are order-sensitive,
|
|
1235
|
+
* matching this module's deterministic emission. A missing filter (undefined)
|
|
1236
|
+
* and an explicit `null` both mean "no filter" and compare equal.
|
|
1237
|
+
*/
|
|
1238
|
+
function partialFilterEqual(a, b) {
|
|
1239
|
+
if (a === b) return true;
|
|
1240
|
+
if (a == null || b == null) return a == null && b == null;
|
|
1241
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
1242
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
1243
|
+
return a.every((item, i) => partialFilterEqual(item, b[i]));
|
|
1244
|
+
}
|
|
1245
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
1246
|
+
const ka = Object.keys(a);
|
|
1247
|
+
const kb = Object.keys(b);
|
|
1248
|
+
if (ka.length !== kb.length) return false;
|
|
1249
|
+
return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && partialFilterEqual(a[k], b[k]));
|
|
1250
|
+
}
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1175
1253
|
function objMatch(o1, o2) {
|
|
1176
1254
|
const keys1 = Object.keys(o1);
|
|
1177
1255
|
const keys2 = Object.keys(o2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/db-mongo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.97",
|
|
4
4
|
"description": "Mongodb plugin for atscript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atscript",
|
|
@@ -46,17 +46,17 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@atscript/core": "^0.1.
|
|
50
|
-
"@atscript/typescript": "^0.1.
|
|
49
|
+
"@atscript/core": "^0.1.70",
|
|
50
|
+
"@atscript/typescript": "^0.1.70",
|
|
51
51
|
"mongodb": "^6.17.0",
|
|
52
52
|
"mongodb-memory-server-core": "^10.0.0",
|
|
53
|
-
"unplugin-atscript": "^0.1.
|
|
53
|
+
"unplugin-atscript": "^0.1.70"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@atscript/core": "^0.1.
|
|
57
|
-
"@atscript/typescript": "^0.1.
|
|
56
|
+
"@atscript/core": "^0.1.70",
|
|
57
|
+
"@atscript/typescript": "^0.1.70",
|
|
58
58
|
"mongodb": "^6.17.0",
|
|
59
|
-
"@atscript/db": "^0.1.
|
|
59
|
+
"@atscript/db": "^0.1.97"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"postinstall": "asc -f dts",
|