@prosopo/database 4.5.1 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +8 -7
  2. package/.turbo/turbo-build$colon$esm.log +8 -7
  3. package/.turbo/turbo-build$colon$tsc.log +15 -15
  4. package/CHANGELOG.md +44 -0
  5. package/dist/cjs/base/index.cjs +3 -0
  6. package/dist/cjs/base/mongo.cjs +5 -1
  7. package/dist/cjs/base/reconcileIndexes.cjs +162 -0
  8. package/dist/cjs/databases/captcha.cjs +20 -21
  9. package/dist/cjs/databases/provider.cjs +56 -29
  10. package/dist/cjs/index.cjs +3 -0
  11. package/dist/esm/base/index.js +2 -1
  12. package/dist/esm/base/mongo.js +5 -1
  13. package/dist/esm/base/reconcileIndexes.js +161 -0
  14. package/dist/esm/databases/captcha.js +20 -21
  15. package/dist/esm/databases/provider.js +56 -29
  16. package/dist/esm/index.js +2 -1
  17. package/dist/tsc/base/index.d.ts +1 -0
  18. package/dist/tsc/base/index.d.ts.map +1 -1
  19. package/dist/tsc/base/index.js +1 -0
  20. package/dist/tsc/base/index.js.map +1 -1
  21. package/dist/tsc/base/mongo.d.ts.map +1 -1
  22. package/dist/tsc/base/mongo.js +5 -1
  23. package/dist/tsc/base/mongo.js.map +1 -1
  24. package/dist/tsc/base/reconcileIndexes.d.ts +24 -0
  25. package/dist/tsc/base/reconcileIndexes.d.ts.map +1 -0
  26. package/dist/tsc/base/reconcileIndexes.js +177 -0
  27. package/dist/tsc/base/reconcileIndexes.js.map +1 -0
  28. package/dist/tsc/databases/captcha.d.ts.map +1 -1
  29. package/dist/tsc/databases/captcha.js +27 -25
  30. package/dist/tsc/databases/captcha.js.map +1 -1
  31. package/dist/tsc/databases/provider.d.ts +2 -8
  32. package/dist/tsc/databases/provider.d.ts.map +1 -1
  33. package/dist/tsc/databases/provider.js +61 -36
  34. package/dist/tsc/databases/provider.js.map +1 -1
  35. package/dist/tsc/tests/integration/insertMissingSpamEmailDomains.integration.test.d.ts +2 -0
  36. package/dist/tsc/tests/integration/insertMissingSpamEmailDomains.integration.test.d.ts.map +1 -0
  37. package/dist/tsc/tests/integration/insertMissingSpamEmailDomains.integration.test.js +91 -0
  38. package/dist/tsc/tests/integration/insertMissingSpamEmailDomains.integration.test.js.map +1 -0
  39. package/dist/tsc/tests/unit/base/mongo.unit.test.js +14 -0
  40. package/dist/tsc/tests/unit/base/mongo.unit.test.js.map +1 -1
  41. package/dist/tsc/tests/unit/base/reconcileIndexes.unit.test.d.ts +2 -0
  42. package/dist/tsc/tests/unit/base/reconcileIndexes.unit.test.d.ts.map +1 -0
  43. package/dist/tsc/tests/unit/base/reconcileIndexes.unit.test.js +350 -0
  44. package/dist/tsc/tests/unit/base/reconcileIndexes.unit.test.js.map +1 -0
  45. package/dist/types/base/index.d.ts +1 -0
  46. package/dist/types/base/index.d.ts.map +1 -1
  47. package/dist/types/base/mongo.d.ts.map +1 -1
  48. package/dist/types/base/reconcileIndexes.d.ts +24 -0
  49. package/dist/types/base/reconcileIndexes.d.ts.map +1 -0
  50. package/dist/types/databases/captcha.d.ts.map +1 -1
  51. package/dist/types/databases/provider.d.ts +2 -8
  52. package/dist/types/databases/provider.d.ts.map +1 -1
  53. package/dist/types/tests/integration/insertMissingSpamEmailDomains.integration.test.d.ts +2 -0
  54. package/dist/types/tests/integration/insertMissingSpamEmailDomains.integration.test.d.ts.map +1 -0
  55. package/dist/types/tests/unit/base/reconcileIndexes.unit.test.d.ts +2 -0
  56. package/dist/types/tests/unit/base/reconcileIndexes.unit.test.d.ts.map +1 -0
  57. package/package.json +7 -7
@@ -34,21 +34,22 @@ ViteCommonJSConfig: .
34
34
  vite v8.1.6 building ssr environment for production...
35
35
  Bundle build started
36
36
  
37
- ✓ 13 modules transformed.
37
+ ✓ 14 modules transformed.
38
38
  rendering chunks...
39
39
  computing gzip size...
40
- dist/cjs/base/index.cjs 0.22 kB │ gzip: 0.10 kB
40
+ dist/cjs/base/index.cjs 0.43 kB │ gzip: 0.16 kB
41
41
  dist/cjs/base/mongoMemory.cjs 0.66 kB │ gzip: 0.32 kB
42
42
  dist/cjs/_virtual/_rolldown/runtime.cjs 1.00 kB │ gzip: 0.50 kB
43
43
  dist/cjs/databases/index.cjs 1.14 kB │ gzip: 0.32 kB
44
44
  dist/cjs/databases/clientRecordCache.cjs 1.45 kB │ gzip: 0.69 kB
45
- dist/cjs/index.cjs 1.66 kB │ gzip: 0.49 kB
45
+ dist/cjs/index.cjs 1.87 kB │ gzip: 0.54 kB
46
46
  dist/cjs/databases/client.cjs 1.96 kB │ gzip: 0.82 kB
47
47
  dist/cjs/mongooseOptions.cjs 3.74 kB │ gzip: 1.56 kB
48
- dist/cjs/base/mongo.cjs 4.29 kB │ gzip: 1.42 kB
48
+ dist/cjs/base/mongo.cjs 4.47 kB │ gzip: 1.49 kB
49
+ dist/cjs/base/reconcileIndexes.cjs 5.97 kB │ gzip: 1.98 kB
49
50
  dist/cjs/databases/centralDbStreamer.cjs 6.40 kB │ gzip: 1.52 kB
50
- dist/cjs/databases/captcha.cjs 8.23 kB │ gzip: 2.38 kB
51
+ dist/cjs/databases/captcha.cjs 8.39 kB │ gzip: 2.40 kB
51
52
  dist/cjs/redisCache.cjs 14.30 kB │ gzip: 4.45 kB
52
- dist/cjs/databases/provider.cjs 71.01 kB │ gzip: 13.57 kB
53
+ dist/cjs/databases/provider.cjs 72.31 kB │ gzip: 13.98 kB
53
54
 
54
- ✓ built in 103ms
55
+ ✓ built in 125ms
@@ -34,21 +34,22 @@ ViteEsmConfig: .
34
34
  vite v8.1.6 building ssr environment for production...
35
35
  Bundle build started
36
36
  
37
- ✓ 13 modules transformed.
37
+ ✓ 14 modules transformed.
38
38
  rendering chunks...
39
39
  computing gzip size...
40
40
  dist/esm/_virtual/_rolldown/runtime.js 0.04 kB │ gzip: 0.06 kB
41
- dist/esm/base/index.js 0.14 kB │ gzip: 0.08 kB
41
+ dist/esm/base/index.js 0.25 kB │ gzip: 0.12 kB
42
42
  dist/esm/base/mongoMemory.js 0.60 kB │ gzip: 0.30 kB
43
43
  dist/esm/databases/index.js 0.77 kB │ gzip: 0.27 kB
44
- dist/esm/index.js 1.07 kB │ gzip: 0.38 kB
44
+ dist/esm/index.js 1.19 kB │ gzip: 0.42 kB
45
45
  dist/esm/databases/clientRecordCache.js 1.40 kB │ gzip: 0.68 kB
46
46
  dist/esm/databases/client.js 1.85 kB │ gzip: 0.80 kB
47
47
  dist/esm/mongooseOptions.js 3.58 kB │ gzip: 1.53 kB
48
- dist/esm/base/mongo.js 4.04 kB │ gzip: 1.33 kB
48
+ dist/esm/base/mongo.js 4.22 kB │ gzip: 1.40 kB
49
+ dist/esm/base/reconcileIndexes.js 5.93 kB │ gzip: 1.97 kB
49
50
  dist/esm/databases/centralDbStreamer.js 6.33 kB │ gzip: 1.51 kB
50
- dist/esm/databases/captcha.js 8.17 kB │ gzip: 2.38 kB
51
+ dist/esm/databases/captcha.js 8.29 kB │ gzip: 2.40 kB
51
52
  dist/esm/redisCache.js 14.25 kB │ gzip: 4.43 kB
52
- dist/esm/databases/provider.js 69.27 kB │ gzip: 13.58 kB
53
+ dist/esm/databases/provider.js 70.50 kB │ gzip: 13.97 kB
53
54
 
54
- ✓ built in 96ms
55
+ ✓ built in 121ms
@@ -1,5 +1,5 @@
1
1
  $ tsc --build --verbose
2
- 3:55:59 PM - Projects in this build:
2
+ 4:57:07 PM - Projects in this build:
3
3
  * ../../dev/config/tsconfig.json
4
4
  * ../util/tsconfig.json
5
5
  * ../locale/tsconfig.json
@@ -14,31 +14,31 @@ $ tsc --build --verbose
14
14
  * ../types-database/tsconfig.json
15
15
  * tsconfig.json
16
16
 
17
- 3:55:59 PM - Project '../../dev/config/tsconfig.json' is up to date because newest input '../../dev/config/src/webpack/webpack.config.ts' is older than output '../../dev/config/dist/tsc/tsconfig.tsbuildinfo'
17
+ 4:57:07 PM - Project '../../dev/config/tsconfig.json' is up to date because newest input '../../dev/config/src/webpack/webpack.config.ts' is older than output '../../dev/config/dist/tsc/tsconfig.tsbuildinfo'
18
18
 
19
- 3:55:59 PM - Project '../util/tsconfig.json' is up to date because newest input '../util/src/url.ts' is older than output '../util/dist/tsc/tsconfig.tsbuildinfo'
19
+ 4:57:07 PM - Project '../util/tsconfig.json' is up to date because newest input '../util/src/url.ts' is older than output '../util/dist/tsc/tsconfig.tsbuildinfo'
20
20
 
21
- 3:55:59 PM - Project '../locale/tsconfig.json' is up to date because newest input '../locale/src/translationKey.ts' is older than output '../locale/dist/tsc/tsconfig.tsbuildinfo'
21
+ 4:57:07 PM - Project '../locale/tsconfig.json' is up to date because newest input '../locale/src/translationKey.ts' is older than output '../locale/dist/tsc/tsconfig.tsbuildinfo'
22
22
 
23
- 3:55:59 PM - Project '../util-crypto/tsconfig.json' is up to date because newest input '../util-crypto/src/types.ts' is older than output '../util-crypto/dist/tsc/tsconfig.tsbuildinfo'
23
+ 4:57:07 PM - Project '../util-crypto/tsconfig.json' is up to date because newest input '../util-crypto/src/types.ts' is older than output '../util-crypto/dist/tsc/tsconfig.tsbuildinfo'
24
24
 
25
- 3:55:59 PM - Project '../types/tsconfig.json' is up to date because newest input '../types/src/types.test-d.ts' is older than output '../types/dist/tsc/tsconfig.tsbuildinfo'
25
+ 4:57:07 PM - Project '../types/tsconfig.json' is up to date because newest input '../types/src/types.test-d.ts' is older than output '../types/dist/tsc/tsconfig.tsbuildinfo'
26
26
 
27
- 3:55:59 PM - Project '../common/tsconfig.json' is up to date because newest input '../common/src/tests/utils/objectUtils.unit.test.ts' is older than output '../common/dist/tsc/tsconfig.tsbuildinfo'
27
+ 4:57:07 PM - Project '../common/tsconfig.json' is up to date because newest input '../common/src/tests/errorSites.unit.test.ts' is older than output '../common/dist/tsc/tsconfig.tsbuildinfo'
28
28
 
29
- 3:55:59 PM - Project '../logger/tsconfig.json' is up to date because newest input '../logger/src/index.ts' is older than output '../logger/dist/tsc/tsconfig.tsbuildinfo'
29
+ 4:57:07 PM - Project '../logger/tsconfig.json' is up to date because newest input '../logger/src/logger.ts' is older than output '../logger/dist/tsc/tsconfig.tsbuildinfo'
30
30
 
31
- 3:55:59 PM - Project '../api-route/tsconfig.json' is up to date because newest input '../api-route/src/apiRoutes.ts' is older than output '../api-route/dist/tsc/tsconfig.tsbuildinfo'
31
+ 4:57:07 PM - Project '../api-route/tsconfig.json' is up to date because newest input '../api-route/src/apiRoutes.ts' is older than output '../api-route/dist/tsc/tsconfig.tsbuildinfo'
32
32
 
33
- 3:55:59 PM - Project '../redis-client/tsconfig.json' is up to date because newest input '../redis-client/src/index.ts' is older than output '../redis-client/dist/tsc/tsconfig.tsbuildinfo'
33
+ 4:57:07 PM - Project '../redis-client/tsconfig.json' is up to date because newest input '../redis-client/src/tests/redisIndex.integration.test.ts' is older than output '../redis-client/dist/tsc/tsconfig.tsbuildinfo'
34
34
 
35
- 3:55:59 PM - Project '../api/tsconfig.json' is up to date because newest input '../api/src/index.ts' is older than output '../api/dist/tsc/tsconfig.tsbuildinfo'
35
+ 4:57:07 PM - Project '../api/tsconfig.json' is up to date because newest input '../api/src/index.ts' is older than output '../api/dist/tsc/tsconfig.tsbuildinfo'
36
36
 
37
- 3:55:59 PM - Project '../user-access-policy/tsconfig.json' is up to date because newest input '../user-access-policy/src/ruleRecord.ts' is older than output '../user-access-policy/dist/tsc/tsconfig.tsbuildinfo'
37
+ 4:57:08 PM - Project '../user-access-policy/tsconfig.json' is up to date because newest input '../user-access-policy/src/transformRule.ts' is older than output '../user-access-policy/dist/tsc/tsconfig.tsbuildinfo'
38
38
 
39
- 3:55:59 PM - Project '../types-database/tsconfig.json' is up to date because newest input '../types-database/src/types/captcha.ts' is older than output '../types-database/dist/tsc/tsconfig.tsbuildinfo'
39
+ 4:57:08 PM - Project '../types-database/tsconfig.json' is up to date because newest input '../types-database/src/types/captcha.ts' is older than output '../types-database/dist/tsc/tsconfig.tsbuildinfo'
40
40
 
41
- 3:55:59 PM - Project 'tsconfig.json' is out of date because output file 'dist/tsc/tsconfig.tsbuildinfo' does not exist
41
+ 4:57:08 PM - Project 'tsconfig.json' is out of date because output file 'dist/tsc/tsconfig.tsbuildinfo' does not exist
42
42
 
43
- 3:55:59 PM - Building project '/home/runner/work/captcha/captcha/packages/database/tsconfig.json'...
43
+ 4:57:08 PM - Building project '/home/runner/work/captcha/captcha/packages/database/tsconfig.json'...
44
44
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # @prosopo/database
2
2
 
3
+ ## 4.6.1
4
+ ### Patch Changes
5
+
6
+ - 007f43c: A puzzle served after a wrong answer is now judged on the device the failed solve showed, and an escalation's solve readings are no longer dropped.
7
+
8
+ - After a wrong puzzle answer, the widget names the failed challenge in a new `retryOf` field on the next `/frictionless` request. `BotDetectionFunction` and `ProviderApi.getFrictionlessCaptcha` take it as a new trailing argument.
9
+ - The provider carries the failed solve's device readings onto the new session only when its own record of that puzzle shows the same client: same site, user account and exact IP address, identical user agent and TLS fingerprint, and a solve that was submitted and failed. The readings come from that solve's own behavioural payload, and each failed puzzle can be followed once (`claimPuzzleCaptchaRetry`). Anything else is ignored.
10
+ - `createSession` now persists `solveDevice`. It was missing from the session's field list, so a PoW escalation's solve readings never reached the challenge they were meant to size.
11
+ - ec54f21: The scheduled spam email domain sync now only writes domains that are not already stored. It used to upsert the whole list (about 225k domains) on every run, which rewrote every row and held Mongo write locks for around 8 seconds per batch on every provider at the same time, slowing requests on the hour. It now reads the stored domains with an index-only scan and upserts just the new ones.
12
+
13
+ `bulkUpdateSpamEmailDomains` on the provider database is replaced by `insertMissingSpamEmailDomains(domains)`, which returns how many domains were added. The task records that count as `domainsAdded` alongside `domainsProcessed`. Domains that drop off the source lists are still kept, as before.
14
+ - Updated dependencies [b80b627]
15
+ - Updated dependencies [007f43c]
16
+ - Updated dependencies [ec54f21]
17
+ - @prosopo/types@5.25.0
18
+ - @prosopo/types-database@5.17.0
19
+ - @prosopo/common@3.3.2
20
+ - @prosopo/user-access-policy@3.15.17
21
+
22
+ ## 4.6.0
23
+ ### Minor Changes
24
+
25
+ - c12d199: Add `reconcileIndexes`, which brings a collection's indexes in line with its schema. Mongoose's own `ensureIndexes`/autoIndex cannot change an existing index: when a declared index's key or options change (for example `pendingStage_partial` gaining `_id`, or a field becoming unique) it logs an error and leaves the old index in place, so the new one never appears. `reconcileIndexes` drops and rebuilds only those clashing indexes, puts the old one back if the rebuild fails (for example a new unique index meeting duplicates), can drop named obsolete indexes, can keep extra indexes declared outside the schema, and has a dry-run mode.
26
+
27
+ The provider's `ensureIndexes` now uses it on its own database (dropping indexes the schema no longer declares). The central captcha database uses it too, but keeps indexes it does not know about, since other services add their own there.
28
+
29
+ ### Patch Changes
30
+
31
+ - f31105a: Mongo connection URLs in logs now hide the username and password properly. The old redaction replaced the first `word:word` it found, so a password containing a `-` (which every password `generateSelfHostedEnv.ts` creates can) was logged in full or in part, with the host and port hidden instead. URLs without credentials no longer lose their host and port either.
32
+ - Updated dependencies [91ef450]
33
+ - Updated dependencies [7c58be4]
34
+ - Updated dependencies [cdea91f]
35
+ - Updated dependencies [f679760]
36
+ - Updated dependencies [cf9a1bb]
37
+ - Updated dependencies [cdea91f]
38
+ - Updated dependencies [ec4ed10]
39
+ - @prosopo/types@5.24.0
40
+ - @prosopo/logger@2.1.8
41
+ - @prosopo/user-access-policy@3.15.16
42
+ - @prosopo/types-database@5.16.2
43
+ - @prosopo/util@3.5.0
44
+ - @prosopo/common@3.3.2
45
+ - @prosopo/redis-client@1.0.45
46
+
3
47
  ## 4.5.1
4
48
  ### Patch Changes
5
49
 
@@ -1,4 +1,7 @@
1
1
  const require_mongo = require("./mongo.cjs");
2
2
  const require_mongoMemory = require("./mongoMemory.cjs");
3
+ const require_reconcileIndexes = require("./reconcileIndexes.cjs");
3
4
  exports.MongoDatabase = require_mongo.MongoDatabase;
4
5
  exports.MongoMemoryDatabase = require_mongoMemory.MongoMemoryDatabase;
6
+ exports.defaultIndexName = require_reconcileIndexes.defaultIndexName;
7
+ exports.reconcileIndexes = require_reconcileIndexes.reconcileIndexes;
@@ -8,6 +8,10 @@ mongoose = require_runtime.__toESM(mongoose, 1);
8
8
  //#region src/base/mongo.ts
9
9
  mongoose.default.set("strictQuery", false);
10
10
  var DEFAULT_ENDPOINT = "mongodb://127.0.0.1:27017";
11
+ var redactCredentials = (url) => {
12
+ const credentials = url.username || url.password ? "<Credentials>@" : "";
13
+ return `${url.protocol}//${credentials}${url.host}${url.pathname}${url.search}`;
14
+ };
11
15
  /**
12
16
  * Returns a generic Mongo database layer
13
17
  * @param {string} url The database endpoint
@@ -23,7 +27,7 @@ var MongoDatabase = class {
23
27
  if (dbname) parsedUrl.pathname = dbname;
24
28
  if (authSource) parsedUrl.searchParams.set("authSource", authSource);
25
29
  this._url = parsedUrl.toString();
26
- this.safeURL = this.url.replace(/\w+:\w+/, "<Credentials>");
30
+ this.safeURL = redactCredentials(parsedUrl);
27
31
  this.dbname = dbname || parsedUrl.pathname.replace("/", "");
28
32
  this.logger = (logger || (0, _prosopo_logger.getLogger)("info", "database:mongo")).with({ mongoUrl: this.safeURL });
29
33
  }
@@ -0,0 +1,162 @@
1
+ //#region src/base/reconcileIndexes.ts
2
+ var defaultIndexName = (key) => Object.entries(key).map(([field, direction]) => `${field}_${direction}`).join("_");
3
+ var toDriverKey = (key) => Object.fromEntries(Object.entries(key).map(([field, direction]) => [field, direction === "ascending" || direction === "asc" ? 1 : direction === "descending" || direction === "desc" ? -1 : direction]));
4
+ var keyOf = (index) => index.key instanceof Map ? Object.fromEntries(index.key) : index.key;
5
+ var sameKey = (a, b) => {
6
+ const left = Object.entries(a);
7
+ const right = Object.entries(b);
8
+ return left.length === right.length && left.every(([field, direction], i) => right[i]?.[0] === field && String(right[i]?.[1]) === String(direction));
9
+ };
10
+ var listIndexesOrEmpty = async (model) => {
11
+ try {
12
+ return await model.listIndexes();
13
+ } catch (err) {
14
+ if (err.codeName === "NamespaceNotFound") return [];
15
+ throw err;
16
+ }
17
+ };
18
+ var toDriverOptions = ({ name, options: { unique, ...options } }) => ({
19
+ ...options,
20
+ ...unique !== void 0 && { unique: Array.isArray(unique) ? unique[0] : unique },
21
+ name
22
+ });
23
+ var errorMessage = (err) => err instanceof Error ? err.message : String(err);
24
+ var specOf = (index) => {
25
+ const { key, v: _v, ns: _ns, ...options } = index;
26
+ return {
27
+ key,
28
+ options
29
+ };
30
+ };
31
+ var BRIDGE_SUFFIX = "_reconcile_bridge";
32
+ var BRIDGE_FIELD = "__reconcileBridge";
33
+ var bridgeOf = (index) => {
34
+ const { options } = specOf(index);
35
+ const { expireAfterSeconds: _ttl, ...bridgeOptions } = options;
36
+ return {
37
+ key: {
38
+ ...index.key,
39
+ [BRIDGE_FIELD]: 1
40
+ },
41
+ options: {
42
+ ...bridgeOptions,
43
+ name: `${index.name}${BRIDGE_SUFFIX}`
44
+ }
45
+ };
46
+ };
47
+ var dropIfPresent = async (model, name) => {
48
+ try {
49
+ await model.collection.dropIndex(name);
50
+ } catch (err) {
51
+ if (err.codeName !== "IndexNotFound") throw err;
52
+ }
53
+ };
54
+ /**
55
+ * Brings a collection's indexes in line with its schema without the window of
56
+ * a drop-everything-then-rebuild. Indexes that already match are left alone;
57
+ * an existing index that clashes with a declared one (same name or same key,
58
+ * different key or options) is rebuilt, because `createIndex` refuses to change
59
+ * an index in place and mongoose's autoIndex only logs that refusal. A bridge
60
+ * index is built before the old one is dropped, so no query loses its index
61
+ * during the rebuild. If a rebuild fails (e.g. a new unique index meets
62
+ * duplicates) the index it replaced is restored, so a failed run never leaves
63
+ * a collection with less than it started with.
64
+ */
65
+ var reconcileIndexes = async (model, options = {}) => {
66
+ const result = {
67
+ collection: model.collection.collectionName,
68
+ dropped: [],
69
+ created: [],
70
+ restored: [],
71
+ failed: []
72
+ };
73
+ const existing = await listIndexesOrEmpty(model);
74
+ const diff = await model.diffIndexes({ indexOptionsToCreate: true });
75
+ const toCreate = diff.toCreate;
76
+ const undeclared = diff.toDrop;
77
+ const wanted = toCreate.map(([key, indexOptions]) => ({
78
+ key,
79
+ options: indexOptions,
80
+ name: indexOptions.name ?? defaultIndexName(key)
81
+ }));
82
+ const extra = options.extra ?? [];
83
+ const isExtra = (index) => extra.some((e) => sameKey(keyOf(e), index.key));
84
+ const missingExtra = extra.filter((e) => !existing.some((index) => sameKey(keyOf(e), index.key)) && !wanted.some((w) => sameKey(keyOf(e), w.key)));
85
+ const obsolete = new Set(options.obsolete ?? []);
86
+ const clashes = (index, w) => w.name === index.name || sameKey(w.key, index.key);
87
+ const toDrop = existing.filter((index) => index.name !== void 0 && undeclared.includes(index.name) && !isExtra(index) && (options.dropUndeclared || obsolete.has(index.name) || wanted.some((w) => clashes(index, w))));
88
+ const extraName = (e) => e.name ?? defaultIndexName(keyOf(e));
89
+ if (options.dryRun) {
90
+ result.dropped = toDrop.map((index) => index.name ?? "");
91
+ result.created = [...wanted.map((w) => w.name), ...missingExtra.map(extraName)];
92
+ return result;
93
+ }
94
+ for (const w of wanted) {
95
+ const replaced = toDrop.filter((index) => clashes(index, w) && !result.dropped.includes(index.name ?? ""));
96
+ const bridges = [];
97
+ try {
98
+ for (const index of replaced) {
99
+ const { key, options: bridgeOptions } = bridgeOf(index);
100
+ await model.collection.createIndex(key, bridgeOptions);
101
+ bridges.push(bridgeOptions.name ?? "");
102
+ }
103
+ } catch (err) {
104
+ result.failed.push({
105
+ name: w.name,
106
+ error: `bridge failed: ${errorMessage(err)}`
107
+ });
108
+ for (const bridge of bridges) await dropIfPresent(model, bridge);
109
+ continue;
110
+ }
111
+ for (const index of replaced) {
112
+ const name = index.name ?? "";
113
+ await dropIfPresent(model, name);
114
+ result.dropped.push(name);
115
+ }
116
+ let restoreFailed = false;
117
+ try {
118
+ await model.collection.createIndex(toDriverKey(w.key), toDriverOptions(w));
119
+ result.created.push(w.name);
120
+ } catch (err) {
121
+ result.failed.push({
122
+ name: w.name,
123
+ error: errorMessage(err)
124
+ });
125
+ for (const index of replaced) {
126
+ const { key, options: replacedOptions } = specOf(index);
127
+ try {
128
+ await model.collection.createIndex(key, replacedOptions);
129
+ result.restored.push(index.name ?? "");
130
+ } catch (restoreErr) {
131
+ restoreFailed = true;
132
+ result.failed.push({
133
+ name: index.name ?? "",
134
+ error: `restore failed: ${errorMessage(restoreErr)}`
135
+ });
136
+ }
137
+ }
138
+ }
139
+ if (!restoreFailed) for (const bridge of bridges) await dropIfPresent(model, bridge);
140
+ }
141
+ for (const e of missingExtra) try {
142
+ await model.collection.createIndexes([e]);
143
+ result.created.push(extraName(e));
144
+ } catch (err) {
145
+ result.failed.push({
146
+ name: extraName(e),
147
+ error: errorMessage(err)
148
+ });
149
+ }
150
+ for (const index of toDrop) {
151
+ const name = index.name ?? "";
152
+ if (!result.dropped.includes(name) && !wanted.some((w) => clashes(index, w))) {
153
+ await dropIfPresent(model, name);
154
+ result.dropped.push(name);
155
+ }
156
+ }
157
+ for (const index of existing) if (index.name?.endsWith(BRIDGE_SUFFIX)) await dropIfPresent(model, index.name);
158
+ return result;
159
+ };
160
+ //#endregion
161
+ exports.defaultIndexName = defaultIndexName;
162
+ exports.reconcileIndexes = reconcileIndexes;
@@ -1,4 +1,5 @@
1
1
  const require_mongo = require("../base/mongo.cjs");
2
+ const require_reconcileIndexes = require("../base/reconcileIndexes.cjs");
2
3
  let _prosopo_common = require("@prosopo/common");
3
4
  let _prosopo_logger = require("@prosopo/logger");
4
5
  let _prosopo_types_database = require("@prosopo/types-database");
@@ -60,27 +61,25 @@ var CaptchaDatabase = class CaptchaDatabase extends require_mongo.MongoDatabase
60
61
  return this.tables;
61
62
  }
62
63
  async ensureIndexes() {
63
- const indexPromises = [];
64
- if (!this.indexesEnsured) CAPTCHA_TABLES.map(({ collectionName }) => {
65
- indexPromises.push(new Promise((resolve) => {
66
- if (this.connected) this.tables[collectionName].collection.dropIndexes().then(() => {
67
- this.tables[collectionName].ensureIndexes().then(() => {
68
- resolve();
69
- }).catch((err) => {
70
- this.logger.warn(() => ({
71
- err,
72
- msg: `Error creating indexes for collection ${collectionName}`
73
- }));
74
- resolve();
75
- });
76
- });
77
- else {
78
- this.logger.info(() => ({ msg: `Skipping index creation for collection ${collectionName} as not connected` }));
79
- resolve();
80
- }
81
- }));
82
- });
83
- await Promise.all(indexPromises);
64
+ if (!this.indexesEnsured && this.connected) await Promise.all(CAPTCHA_TABLES.map(async ({ collectionName }) => {
65
+ try {
66
+ const result = await require_reconcileIndexes.reconcileIndexes(this.tables[collectionName], { dropUndeclared: false });
67
+ if (result.failed.length > 0) this.logger.warn(() => ({
68
+ msg: `Error creating indexes for collection ${collectionName}`,
69
+ data: result
70
+ }));
71
+ else this.logger.info(() => ({
72
+ msg: `Indexes ensured for collection ${collectionName}`,
73
+ data: result
74
+ }));
75
+ } catch (err) {
76
+ this.logger.warn(() => ({
77
+ err,
78
+ msg: `Error creating indexes for collection ${collectionName}`
79
+ }));
80
+ }
81
+ }));
82
+ else if (!this.connected) this.logger.info(() => ({ msg: "Skipping index creation as not connected" }));
84
83
  this.indexesEnsured = true;
85
84
  }
86
85
  /**
@@ -1,4 +1,5 @@
1
1
  const require_mongo = require("../base/mongo.cjs");
2
+ const require_reconcileIndexes = require("../base/reconcileIndexes.cjs");
2
3
  const require_clientRecordCache = require("./clientRecordCache.cjs");
3
4
  let _prosopo_common = require("@prosopo/common");
4
5
  let _prosopo_types = require("@prosopo/types");
@@ -147,28 +148,25 @@ var ProviderDatabase = class ProviderDatabase extends require_mongo.MongoDatabas
147
148
  return this.redisConnection;
148
149
  }
149
150
  async ensureIndexes() {
150
- const indexPromises = [];
151
- if (!this.indexesEnsured) PROVIDER_TABLES.map(({ collectionName }) => {
152
- indexPromises.push(new Promise((resolve) => {
153
- if (this.connected) this.tables[collectionName].collection.dropIndexes().then(() => {
154
- this.tables[collectionName].ensureIndexes().then(() => {
155
- this.logger.info(() => ({ msg: `Indexes ensured for collection ${collectionName}` }));
156
- resolve();
157
- }).catch((err) => {
158
- this.logger.warn(() => ({
159
- err,
160
- msg: `Error creating indexes for collection ${collectionName}`
161
- }));
162
- resolve();
163
- });
164
- });
165
- else {
166
- this.logger.info(() => ({ msg: `Skipping index creation for collection ${collectionName} as not connected` }));
167
- resolve();
168
- }
169
- }));
170
- });
171
- await Promise.all(indexPromises);
151
+ if (!this.indexesEnsured && this.connected) await Promise.all(PROVIDER_TABLES.map(async ({ collectionName }) => {
152
+ try {
153
+ const result = await require_reconcileIndexes.reconcileIndexes(this.tables[collectionName], { dropUndeclared: true });
154
+ if (result.failed.length > 0) this.logger.warn(() => ({
155
+ msg: `Error creating indexes for collection ${collectionName}`,
156
+ data: result
157
+ }));
158
+ else this.logger.info(() => ({
159
+ msg: `Indexes ensured for collection ${collectionName}`,
160
+ data: result
161
+ }));
162
+ } catch (err) {
163
+ this.logger.warn(() => ({
164
+ err,
165
+ msg: `Error creating indexes for collection ${collectionName}`
166
+ }));
167
+ }
168
+ }));
169
+ else if (!this.connected) this.logger.info(() => ({ msg: "Skipping index creation as not connected" }));
172
170
  this.indexesEnsured = true;
173
171
  }
174
172
  getUserAccessRulesStorage() {
@@ -1054,6 +1052,19 @@ var ProviderDatabase = class ProviderDatabase extends require_mongo.MongoDatabas
1054
1052
  async updatePuzzleCaptchaRecord(challenge, updates) {
1055
1053
  await this.updateInteractiveCaptchaRecord(this.interactiveCollection(_prosopo_types.CaptchaType.puzzle), challenge, updates);
1056
1054
  }
1055
+ /**
1056
+ * Take a failed puzzle for one retry. Succeeds once per puzzle, and only
1057
+ * for a puzzle that was submitted and disapproved.
1058
+ * @returns true when this call took it
1059
+ */
1060
+ async claimPuzzleCaptchaRetry(challenge) {
1061
+ return (await this.getTables()[this.interactiveCollection(_prosopo_types.CaptchaType.puzzle).table].updateOne({
1062
+ challenge,
1063
+ userSubmitted: true,
1064
+ "result.status": _prosopo_types.CaptchaStatus.disapproved,
1065
+ retried: { $ne: true }
1066
+ }, { $set: { retried: true } })).modifiedCount === 1;
1067
+ }
1057
1068
  async updateIconOrderCaptchaRecord(challenge, updates) {
1058
1069
  await this.updateInteractiveCaptchaRecord(this.interactiveCollection(_prosopo_types.CaptchaType.iconOrder), challenge, updates);
1059
1070
  }
@@ -2087,19 +2098,35 @@ var ProviderDatabase = class ProviderDatabase extends require_mongo.MongoDatabas
2087
2098
  const query = suffixCandidates.length > 0 ? { domain: { $in: suffixCandidates } } : { domain };
2088
2099
  return await this.tables.spamEmailDomain.findOne(query).exec();
2089
2100
  }
2090
- async bulkUpdateSpamEmailDomains(domains, upsert) {
2101
+ /**
2102
+ * Store any of `domains` not already in the collection and return how many
2103
+ * were added. The source lists hold ~225k domains and barely change between
2104
+ * runs, so upserting all of them rewrote every row and held write locks for
2105
+ * ~8s per batch on each provider at the same moment. Reading the existing
2106
+ * set first is an index-only scan, and only the difference is written.
2107
+ */
2108
+ async insertMissingSpamEmailDomains(domains) {
2091
2109
  if (!this.tables?.spamEmailDomain) throw new _prosopo_common.ProsopoError({
2092
2110
  code: "DB_QUERY_FAILED",
2093
2111
  errorSite: "DBSE-51",
2094
2112
  message: "Failed to import database engine",
2095
- context: { failedFuncName: this.bulkUpdateSpamEmailDomains.name }
2113
+ context: { failedFuncName: this.insertMissingSpamEmailDomains.name }
2096
2114
  });
2097
- const bulkOps = domains.map((op) => ({ updateOne: {
2098
- filter: op.filter,
2099
- update: { $set: op.update },
2100
- upsert
2115
+ const missing = new Set(domains);
2116
+ if (missing.size === 0) return 0;
2117
+ const existing = this.tables.spamEmailDomain.find({}, {
2118
+ domain: 1,
2119
+ _id: 0
2120
+ }).hint({ domain: 1 }).lean().cursor();
2121
+ for await (const record of existing) missing.delete(record.domain);
2122
+ if (missing.size === 0) return 0;
2123
+ const bulkOps = Array.from(missing, (domain) => ({ updateOne: {
2124
+ filter: { domain },
2125
+ update: { $set: { domain } },
2126
+ upsert: true
2101
2127
  } }));
2102
- if (bulkOps.length > 0) await this.tables.spamEmailDomain.bulkWrite(bulkOps);
2128
+ await this.tables.spamEmailDomain.bulkWrite(bulkOps, { ordered: false });
2129
+ return missing.size;
2103
2130
  }
2104
2131
  };
2105
2132
  //#endregion
@@ -2,6 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_mongooseOptions = require("./mongooseOptions.cjs");
3
3
  const require_mongo = require("./base/mongo.cjs");
4
4
  const require_mongoMemory = require("./base/mongoMemory.cjs");
5
+ const require_reconcileIndexes = require("./base/reconcileIndexes.cjs");
5
6
  const require_captcha = require("./databases/captcha.cjs");
6
7
  const require_client = require("./databases/client.cjs");
7
8
  const require_provider = require("./databases/provider.cjs");
@@ -23,5 +24,7 @@ exports.MongoDatabase = require_mongo.MongoDatabase;
23
24
  exports.MongoMemoryDatabase = require_mongoMemory.MongoMemoryDatabase;
24
25
  exports.ProviderDatabase = require_provider.ProviderDatabase;
25
26
  exports.RedisWriteQueue = require_redisCache.RedisWriteQueue;
27
+ exports.defaultIndexName = require_reconcileIndexes.defaultIndexName;
26
28
  exports.getMongoCompressors = require_mongooseOptions.getMongoCompressors;
27
29
  exports.getMongoConnectionOptions = require_mongooseOptions.getMongoConnectionOptions;
30
+ exports.reconcileIndexes = require_reconcileIndexes.reconcileIndexes;
@@ -1,3 +1,4 @@
1
1
  import { MongoDatabase } from "./mongo.js";
2
2
  import { MongoMemoryDatabase } from "./mongoMemory.js";
3
- export { MongoDatabase, MongoMemoryDatabase };
3
+ import { defaultIndexName, reconcileIndexes } from "./reconcileIndexes.js";
4
+ export { MongoDatabase, MongoMemoryDatabase, defaultIndexName, reconcileIndexes };
@@ -6,6 +6,10 @@ import mongoose from "mongoose";
6
6
  //#region src/base/mongo.ts
7
7
  mongoose.set("strictQuery", false);
8
8
  var DEFAULT_ENDPOINT = "mongodb://127.0.0.1:27017";
9
+ var redactCredentials = (url) => {
10
+ const credentials = url.username || url.password ? "<Credentials>@" : "";
11
+ return `${url.protocol}//${credentials}${url.host}${url.pathname}${url.search}`;
12
+ };
9
13
  /**
10
14
  * Returns a generic Mongo database layer
11
15
  * @param {string} url The database endpoint
@@ -21,7 +25,7 @@ var MongoDatabase = class {
21
25
  if (dbname) parsedUrl.pathname = dbname;
22
26
  if (authSource) parsedUrl.searchParams.set("authSource", authSource);
23
27
  this._url = parsedUrl.toString();
24
- this.safeURL = this.url.replace(/\w+:\w+/, "<Credentials>");
28
+ this.safeURL = redactCredentials(parsedUrl);
25
29
  this.dbname = dbname || parsedUrl.pathname.replace("/", "");
26
30
  this.logger = (logger || getLogger("info", "database:mongo")).with({ mongoUrl: this.safeURL });
27
31
  }