@atscript/db-mongo 0.1.96 → 0.1.98

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 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
- if ((local.type === "text" || objMatch(local.fields, remote.key)) && objMatch(local.weights || {}, remote.weights || {})) indexesToCreate.delete(remote.name);
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
- if ((local.type === "text" || objMatch(local.fields, remote.key)) && objMatch(local.weights || {}, remote.weights || {})) indexesToCreate.delete(remote.name);
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.96",
3
+ "version": "0.1.98",
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.69",
50
- "@atscript/typescript": "^0.1.69",
49
+ "@atscript/core": "^0.1.71",
50
+ "@atscript/typescript": "^0.1.71",
51
51
  "mongodb": "^6.17.0",
52
52
  "mongodb-memory-server-core": "^10.0.0",
53
- "unplugin-atscript": "^0.1.69"
53
+ "unplugin-atscript": "^0.1.71"
54
54
  },
55
55
  "peerDependencies": {
56
- "@atscript/core": "^0.1.69",
57
- "@atscript/typescript": "^0.1.69",
56
+ "@atscript/core": "^0.1.71",
57
+ "@atscript/typescript": "^0.1.71",
58
58
  "mongodb": "^6.17.0",
59
- "@atscript/db": "^0.1.96"
59
+ "@atscript/db": "^0.1.98"
60
60
  },
61
61
  "scripts": {
62
62
  "postinstall": "asc -f dts",