@serve.zone/dcrouter 32.1.1 → 32.1.2

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 (48) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +2 -2
  4. package/dist_ts/config/classes.gateway-client-manager.js +1 -1
  5. package/dist_ts/db/documents/classes.cached.email.d.ts +0 -1
  6. package/dist_ts/db/documents/classes.cached.email.js +45 -48
  7. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +0 -1
  8. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +61 -50
  9. package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +9 -2
  10. package/dist_ts/db/documents/classes.gateway-client.doc.js +9 -11
  11. package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +4 -3
  12. package/dist_ts/db/documents/classes.ip-intelligence.doc.js +27 -31
  13. package/dist_ts/db/documents/classes.ops-config-event.doc.d.ts +0 -2
  14. package/dist_ts/db/documents/classes.ops-config-event.doc.js +70 -68
  15. package/dist_ts/db/documents/classes.smartmta-storage.doc.d.ts +14 -0
  16. package/dist_ts/db/documents/classes.smartmta-storage.doc.js +17 -3
  17. package/dist_ts/db/documents/classes.webpush-admission.doc.d.ts +0 -1
  18. package/dist_ts/db/documents/classes.webpush-admission.doc.js +14 -5
  19. package/dist_ts/db/documents/classes.webpush-binding.doc.d.ts +1 -1
  20. package/dist_ts/db/documents/classes.webpush-binding.doc.js +45 -7
  21. package/dist_ts/db/documents/classes.webpush-spool.doc.d.ts +0 -1
  22. package/dist_ts/db/documents/classes.webpush-spool.doc.js +33 -5
  23. package/dist_ts/dns/manager.dns.js +7 -3
  24. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +15 -3
  25. package/dist_ts/email/classes.smartmta-storage-manager.js +62 -36
  26. package/dist_ts/security/classes.security-policy-manager.js +11 -9
  27. package/dist_ts/webpush/classes.webpush-manager.d.ts +5 -1
  28. package/dist_ts/webpush/classes.webpush-manager.js +162 -232
  29. package/dist_ts_web/00_commitinfo_data.js +1 -1
  30. package/package.json +3 -3
  31. package/readme.md +1 -0
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts/classes.dcrouter.ts +1 -1
  34. package/ts/config/classes.gateway-client-manager.ts +4 -2
  35. package/ts/db/documents/classes.cached.email.ts +51 -66
  36. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +65 -59
  37. package/ts/db/documents/classes.gateway-client.doc.ts +38 -18
  38. package/ts/db/documents/classes.ip-intelligence.doc.ts +37 -40
  39. package/ts/db/documents/classes.ops-config-event.doc.ts +80 -77
  40. package/ts/db/documents/classes.smartmta-storage.doc.ts +16 -1
  41. package/ts/db/documents/classes.webpush-admission.doc.ts +14 -5
  42. package/ts/db/documents/classes.webpush-binding.doc.ts +53 -7
  43. package/ts/db/documents/classes.webpush-spool.doc.ts +39 -5
  44. package/ts/dns/manager.dns.ts +6 -2
  45. package/ts/email/classes.smartmta-storage-manager.ts +66 -50
  46. package/ts/security/classes.security-policy-manager.ts +10 -8
  47. package/ts/webpush/classes.webpush-manager.ts +175 -280
  48. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -9,6 +9,20 @@ import type {
9
9
 
10
10
  const getDb = () => DcRouterDb.getInstance().getDb();
11
11
 
12
+ const PRUNE_BATCH_SIZE = 500;
13
+
14
+ /**
15
+ * "Not acknowledged yet" as a typed predicate: the field is absent (how the ORM stores
16
+ * an unset optional field) or explicitly null (how older documents may carry it). This
17
+ * is exactly what the previous `{ acknowledgedAt: null }` selector matched.
18
+ */
19
+ const unacknowledgedBranches = (): Array<{
20
+ acknowledgedAt: { $exists: false } | { $type: 'null' };
21
+ }> => [
22
+ { acknowledgedAt: { $exists: false } },
23
+ { acknowledgedAt: { $type: 'null' } },
24
+ ];
25
+
12
26
  export interface IOpsConfigEventFilter {
13
27
  acknowledged?: boolean;
14
28
  severity?: TOpsConfigEventSeverity;
@@ -87,21 +101,6 @@ export class OpsConfigEventDoc extends plugins.smartdata.SmartDataDbDoc<OpsConfi
87
101
  return savable;
88
102
  }
89
103
 
90
- private static async getNativeCollection() {
91
- const smartdataCollection = (OpsConfigEventDoc as typeof OpsConfigEventDoc & {
92
- collection: plugins.smartdata.SmartdataCollection<OpsConfigEventDoc>;
93
- }).collection;
94
- await smartdataCollection.init();
95
- const probe = new OpsConfigEventDoc();
96
- await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
97
- await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
98
- return smartdataCollection.mongoDbCollection;
99
- }
100
-
101
- private static hydrate(rawArg: Record<string, unknown>): OpsConfigEventDoc {
102
- return OpsConfigEventDoc.createInstanceFromMongoDbNativeDoc(rawArg as any);
103
- }
104
-
105
104
  public static async upsertOpenEvent(optionsArg: {
106
105
  id: string;
107
106
  openDedupeKey: string;
@@ -112,9 +111,13 @@ export class OpsConfigEventDoc extends plugins.smartdata.SmartDataDbDoc<OpsConfi
112
111
  context: IOpsConfigEventContext;
113
112
  createdAt: number;
114
113
  }): Promise<{ event: OpsConfigEventDoc; created: boolean }> {
115
- const collection = await OpsConfigEventDoc.getNativeCollection();
116
- const timestamp = new Date(optionsArg.createdAt).toISOString();
117
- const result = await collection.updateOne(
114
+ // Deduplication is arbitrated by the sparse unique `openDedupeKey` index: the filter
115
+ // anchors on that key, so two writers racing the same open occurrence either update
116
+ // it or lose the insert branch to the index. `$setOnInsert` seeds the `@unI()`
117
+ // identity on the insert branch only — the client refuses an identity seed without
118
+ // `upsert: true` and refuses a seed that contradicts an identity anchor in the
119
+ // filter, so the created event is addressable by `id` and immutable afterwards.
120
+ const result = await OpsConfigEventDoc.atomicUpdate<OpsConfigEventDoc>(
118
121
  { openDedupeKey: optionsArg.openDedupeKey },
119
122
  {
120
123
  $set: {
@@ -122,80 +125,72 @@ export class OpsConfigEventDoc extends plugins.smartdata.SmartDataDbDoc<OpsConfi
122
125
  detail: optionsArg.detail,
123
126
  context: optionsArg.context,
124
127
  createdAt: optionsArg.createdAt,
125
- _updatedAt: timestamp,
126
128
  },
127
129
  $setOnInsert: {
128
130
  id: optionsArg.id,
129
131
  category: optionsArg.category,
130
132
  title: optionsArg.title,
131
133
  openDedupeKey: optionsArg.openDedupeKey,
132
- _createdAt: timestamp,
133
134
  },
134
135
  },
135
136
  { upsert: true },
136
137
  );
137
- const raw = await collection.findOne({ openDedupeKey: optionsArg.openDedupeKey });
138
- if (!raw) throw new Error('Failed to read the recorded platform configuration event');
139
- return {
140
- event: OpsConfigEventDoc.hydrate(raw as Record<string, unknown>),
141
- created: result.upsertedCount > 0,
142
- };
138
+ const event = await OpsConfigEventDoc.getInstance<OpsConfigEventDoc>({
139
+ openDedupeKey: optionsArg.openDedupeKey,
140
+ });
141
+ if (!event) throw new Error('Failed to read the recorded platform configuration event');
142
+ return { event, created: result.upsertedId !== null };
143
143
  }
144
144
 
145
145
  public static async findFiltered(
146
146
  filter: IOpsConfigEventFilter = {},
147
147
  ): Promise<{ events: OpsConfigEventDoc[]; total: number; nextCursor?: IOpsConfigEventCursor }> {
148
- const selector: Record<string, unknown> = {};
148
+ const selector: plugins.smartdata.MongoFilter<OpsConfigEventDoc> = {};
149
149
  if (filter.acknowledged === true) selector.acknowledgedAt = { $gt: 0 };
150
- if (filter.acknowledged === false) selector.acknowledgedAt = null;
150
+ if (filter.acknowledged === false) Object.assign(selector, { $or: unacknowledgedBranches() });
151
151
  if (filter.severity) selector.severity = filter.severity;
152
152
  if (filter.category) selector.category = filter.category;
153
153
  const requestedLimit = Number.isSafeInteger(filter.limit) && filter.limit! > 0
154
154
  ? filter.limit!
155
155
  : 100;
156
156
  const limit = Math.min(requestedLimit, 200);
157
- const countSelector = structuredClone(selector);
158
157
  if (filter.cursor) {
159
158
  if (!Number.isSafeInteger(filter.cursor.createdAt) || filter.cursor.createdAt < 0
160
159
  || typeof filter.cursor.id !== 'string' || !filter.cursor.id.trim()) {
161
160
  throw new Error('Invalid platform configuration event cursor');
162
161
  }
163
- selector.$or = [
164
- { createdAt: { $lt: filter.cursor.createdAt } },
165
- { createdAt: filter.cursor.createdAt, id: { $lt: filter.cursor.id } },
166
- ];
167
162
  }
168
- const collection = await OpsConfigEventDoc.getNativeCollection();
169
- const cursor = collection
170
- .find(selector)
171
- .sort({ createdAt: -1, id: -1 })
172
- .limit(limit + 1);
173
- try {
174
- const [rawEvents, total] = await Promise.all([
175
- cursor.toArray(),
176
- collection.countDocuments(countSelector),
177
- ]);
178
- const hasMore = rawEvents.length > limit;
179
- const pageRows = rawEvents.slice(0, limit);
180
- const lastRow = pageRows.at(-1) as Record<string, unknown> | undefined;
181
- return {
182
- events: pageRows.map((rawArg) => OpsConfigEventDoc.hydrate(rawArg as Record<string, unknown>)),
183
- total,
184
- ...(hasMore && lastRow ? {
163
+ // Seek pagination over (createdAt, id) with the unique id as tiebreaker, plus the
164
+ // unpaged total for the same filter.
165
+ const page = await OpsConfigEventDoc.getPagedInstances<OpsConfigEventDoc>({
166
+ filter: selector,
167
+ sortField: 'createdAt',
168
+ uniqueField: 'id',
169
+ sortDirection: 'desc',
170
+ limit,
171
+ withTotal: true,
172
+ ...(filter.cursor
173
+ ? { cursor: { sortValue: filter.cursor.createdAt, uniqueValue: filter.cursor.id } }
174
+ : {}),
175
+ });
176
+ return {
177
+ events: page.documents,
178
+ total: page.total ?? 0,
179
+ ...(page.nextCursor
180
+ ? {
185
181
  nextCursor: {
186
- createdAt: Number(lastRow.createdAt),
187
- id: String(lastRow.id),
182
+ createdAt: Number(page.nextCursor.sortValue),
183
+ id: String(page.nextCursor.uniqueValue),
188
184
  },
189
- } : {}),
190
- };
191
- } finally {
192
- await cursor.close();
193
- }
185
+ }
186
+ : {}),
187
+ };
194
188
  }
195
189
 
196
190
  public static async countUnacknowledged(): Promise<number> {
197
- const collection = await OpsConfigEventDoc.getNativeCollection();
198
- return await collection.countDocuments({ acknowledgedAt: null });
191
+ return await OpsConfigEventDoc.getCount<OpsConfigEventDoc>({
192
+ $or: unacknowledgedBranches(),
193
+ });
199
194
  }
200
195
 
201
196
  /** Acknowledge the given event ids; returns how many were newly acknowledged. */
@@ -208,16 +203,23 @@ export class OpsConfigEventDoc extends plugins.smartdata.SmartDataDbDoc<OpsConfi
208
203
  }
209
204
  const uniqueIds = Array.from(new Set(ids));
210
205
  if (uniqueIds.length === 0) return 0;
211
- const collection = await OpsConfigEventDoc.getNativeCollection();
212
206
  const now = Date.now();
213
- const result = await collection.updateMany(
214
- { id: { $in: uniqueIds }, acknowledgedAt: null },
207
+ // The per-id `$or` is the equality anchor a plural atomic write requires; the second
208
+ // branch keeps the "only newly acknowledged rows count" guard.
209
+ const result = await OpsConfigEventDoc.atomicUpdateMany<OpsConfigEventDoc>(
210
+ {
211
+ $and: [
212
+ { $or: uniqueIds.map((idArg) => ({ id: idArg })) },
213
+ { $or: unacknowledgedBranches() },
214
+ ],
215
+ },
215
216
  {
216
217
  $set: {
217
218
  acknowledgedBy: userId,
218
219
  acknowledgedAt: now,
219
- _updatedAt: new Date(now).toISOString(),
220
220
  },
221
+ // Presence of openDedupeKey is the open-event uniqueness authority, so
222
+ // acknowledging must release it.
221
223
  $unset: { openDedupeKey: '' },
222
224
  },
223
225
  );
@@ -228,27 +230,28 @@ export class OpsConfigEventDoc extends plugins.smartdata.SmartDataDbDoc<OpsConfi
228
230
  public static async pruneAcknowledged(maxAgeMs: number): Promise<number> {
229
231
  const cutoff = Date.now() - maxAgeMs;
230
232
  let pruned = 0;
231
- const collection = await OpsConfigEventDoc.getNativeCollection();
232
233
  while (true) {
233
- const cursor = collection
234
- .find(
235
- { acknowledgedAt: { $lt: cutoff } },
236
- { projection: { _id: 1 } },
237
- )
238
- .sort({ acknowledgedAt: 1, _id: 1 })
239
- .limit(500);
240
- let rows: Array<{ _id: unknown }>;
234
+ const cursor = await OpsConfigEventDoc.getCursor<OpsConfigEventDoc>(
235
+ { acknowledgedAt: { $lt: cutoff } },
236
+ {
237
+ projection: { id: 1 },
238
+ sort: { acknowledgedAt: 1, id: 1 },
239
+ limit: PRUNE_BATCH_SIZE,
240
+ },
241
+ );
242
+ let rows: OpsConfigEventDoc[];
241
243
  try {
242
- rows = await cursor.toArray() as Array<{ _id: unknown }>;
244
+ rows = await cursor.toArray();
243
245
  } finally {
244
246
  await cursor.close();
245
247
  }
246
248
  if (rows.length === 0) break;
247
- const deleteFilter: Record<string, any> = {
248
- _id: { $in: rows.map((rowArg) => rowArg._id) },
249
+ // Anchored on the scanned identities, with the retention guard re-evaluated
250
+ // server-side so an event acknowledged after the scan is never pruned early.
251
+ const result = await OpsConfigEventDoc.atomicDeleteMany<OpsConfigEventDoc>({
252
+ $or: rows.map((rowArg) => ({ id: rowArg.id })),
249
253
  acknowledgedAt: { $lt: cutoff },
250
- };
251
- const result = await collection.deleteMany(deleteFilter);
254
+ });
252
255
  if (result.deletedCount === 0) break;
253
256
  pruned += result.deletedCount;
254
257
  }
@@ -3,11 +3,26 @@ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
3
 
4
4
  const getDb = () => DcRouterDb.getInstance().getDb();
5
5
 
6
- @plugins.smartdata.Collection(() => getDb())
6
+ /**
7
+ * SmartMTA's durable key/value space.
8
+ *
9
+ * Every document is content-addressed: `id` is `sha256(normalizedKey)` and owns the stored
10
+ * `_id`, so the primary key — not a secondary index — is the uniqueness authority that makes
11
+ * a write a single race-free upsert. That is what `identityAsDocumentId: 'id'` declares, and
12
+ * it holds for every row dcrouter ever wrote here, because the manager has always derived
13
+ * both `_id` and `id` from the same hash.
14
+ *
15
+ * The declaration is irreversible: from here on reads are answered through `_id`, so a row
16
+ * whose `_id` is not its identity would no longer resolve, and the client refuses a unique
17
+ * index on `id` because the primary key is the only uniqueness authority a document-id
18
+ * identity may have.
19
+ */
20
+ @plugins.smartdata.Collection(() => getDb(), { identityAsDocumentId: 'id' })
7
21
  export class SmartMtaStorageDoc extends plugins.smartdata.SmartDataDbDoc<
8
22
  SmartMtaStorageDoc,
9
23
  SmartMtaStorageDoc
10
24
  > {
25
+ @plugins.smartdata.unI()
11
26
  @plugins.smartdata.svDb()
12
27
  public id!: string;
13
28
 
@@ -9,6 +9,20 @@ const getDb = () => DcRouterDb.getInstance().getDb();
9
9
  * A credential rotation must not reset these counters, so the stable binding
10
10
  * id and lifecycle generation are the authority rather than a credential id.
11
11
  */
12
+ // Both indexes are declared under the exact names the released Web Push schema migration
13
+ // created them with (`ts_migrations/index.ts` via `ts/webpush/webpush-indexes.ts`), so model
14
+ // initialization verifies the existing topology instead of installing a second, differently
15
+ // named index over the same key.
16
+ @plugins.smartdata.namedIndex({
17
+ name: 'web_push_admission_binding_unique',
18
+ key: { bindingId: 1, lifecycleGeneration: 1 },
19
+ options: { unique: true },
20
+ })
21
+ @plugins.smartdata.namedIndex({
22
+ name: 'web_push_admission_ttl',
23
+ key: { purgeAt: 1 },
24
+ options: { expireAfterSeconds: 0 },
25
+ })
12
26
  @plugins.smartdata.Collection(() => getDb())
13
27
  export class WebPushAdmissionDoc extends plugins.smartdata.SmartDataDbDoc<
14
28
  WebPushAdmissionDoc,
@@ -40,9 +54,4 @@ export class WebPushAdmissionDoc extends plugins.smartdata.SmartDataDbDoc<
40
54
 
41
55
  @plugins.smartdata.svDb()
42
56
  public purgeAt!: Date;
43
-
44
- public static getNativeCollection():
45
- plugins.smartdata.SmartdataCollection<WebPushAdmissionDoc>['mongoDbCollection'] {
46
- return getDb().mongoDb.collection('WebPushAdmissionDoc');
47
- }
48
57
  }
@@ -47,6 +47,53 @@ export interface IWebPushStoredControllerFence {
47
47
 
48
48
  const getDb = () => DcRouterDb.getInstance().getDb();
49
49
 
50
+ // Every index is declared under the exact name the released Web Push schema migration created
51
+ // it with (`ts_migrations/index.ts` via `ts/webpush/webpush-indexes.ts`), so model initialization
52
+ // verifies the existing topology instead of installing a second, differently named index over the
53
+ // same key. `test/test.webpush-persisted-bytes.node.ts` keeps both declarations in lockstep.
54
+ @plugins.smartdata.namedIndex({
55
+ name: 'web_push_binding_id_unique',
56
+ key: { id: 1 },
57
+ options: { unique: true },
58
+ })
59
+ @plugins.smartdata.namedIndex({
60
+ name: 'web_push_binding_owner_unique',
61
+ key: { ownerGatewayClientType: 1, ownerGatewayClientId: 1, ownerAppInstanceId: 1 },
62
+ options: { unique: true },
63
+ })
64
+ @plugins.smartdata.namedIndex({
65
+ name: 'web_push_binding_credential_unique',
66
+ key: { 'credential.id': 1 },
67
+ options: { unique: true },
68
+ })
69
+ @plugins.smartdata.namedIndex({
70
+ name: 'web_push_binding_previous_credential_lookup',
71
+ key: { 'previousCredentials.id': 1 },
72
+ })
73
+ @plugins.smartdata.namedIndex({
74
+ name: 'web_push_binding_previous_credential_cleanup',
75
+ key: { 'previousCredentials.validUntil': 1 },
76
+ })
77
+ @plugins.smartdata.namedIndex({
78
+ name: 'web_push_binding_rotation_recovery_cleanup',
79
+ key: { 'credentialRotationRecovery.expiresAt': 1 },
80
+ })
81
+ @plugins.smartdata.namedIndex({
82
+ name: 'web_push_binding_vapid_status_cleanup',
83
+ key: { 'vapidKeys.status': 1 },
84
+ })
85
+ @plugins.smartdata.namedIndex({
86
+ name: 'web_push_binding_vapid_retire_after_cleanup',
87
+ key: { 'vapidKeys.retireAfter': 1 },
88
+ })
89
+ @plugins.smartdata.namedIndex({
90
+ name: 'web_push_binding_disabled_cleanup',
91
+ key: { enabled: 1, lifecycleGeneration: 1 },
92
+ })
93
+ @plugins.smartdata.namedIndex({
94
+ name: 'web_push_binding_deleted_cleanup',
95
+ key: { deletedAt: 1, lifecycleGeneration: 1 },
96
+ })
50
97
  @plugins.smartdata.Collection(() => getDb())
51
98
  export class WebPushBindingDoc extends plugins.smartdata.SmartDataDbDoc<
52
99
  WebPushBindingDoc,
@@ -70,7 +117,8 @@ export class WebPushBindingDoc extends plugins.smartdata.SmartDataDbDoc<
70
117
  @plugins.smartdata.svDb()
71
118
  public status!: plugins.servezoneInterfaces.data.TWebPushBindingStatus;
72
119
 
73
- @plugins.smartdata.svDb()
120
+ /** `credential.id` is the compare-and-set anchor of credential rotation and a unique index. */
121
+ @plugins.smartdata.svDb({ atomicPaths: ['id'] })
74
122
  public credential!: IWebPushStoredCredential;
75
123
 
76
124
  /** At most one still-valid overlap credential; expired entries are scrubbed. */
@@ -78,7 +126,10 @@ export class WebPushBindingDoc extends plugins.smartdata.SmartDataDbDoc<
78
126
  public previousCredentials: IWebPushStoredCredential[] = [];
79
127
 
80
128
  /** MUST BE ABSENT outside the bounded idempotent credential-rotation replay window. */
81
- @plugins.smartdata.svDb()
129
+ @plugins.smartdata.svDb({
130
+ // Expiry cleanup fences on the exact recovery record it observed.
131
+ atomicPaths: ['requestedCredentialId', 'resultingCredentialId', 'expiresAt'],
132
+ })
82
133
  public credentialRotationRecovery?: IWebPushCredentialRotationRecovery;
83
134
 
84
135
  @plugins.smartdata.svDb()
@@ -107,11 +158,6 @@ export class WebPushBindingDoc extends plugins.smartdata.SmartDataDbDoc<
107
158
  @plugins.smartdata.svDb()
108
159
  public deletedAt?: number;
109
160
 
110
- public static getNativeCollection():
111
- plugins.smartdata.SmartdataCollection<WebPushBindingDoc>['mongoDbCollection'] {
112
- return getDb().mongoDb.collection('WebPushBindingDoc');
113
- }
114
-
115
161
  public override async createSavableObject(): Promise<WebPushBindingDoc> {
116
162
  const savable = await super.createSavableObject();
117
163
  if (savable.credential.secretHash === undefined) {
@@ -4,6 +4,45 @@ import type { IWebPushEncryptedEnvelope } from '../../webpush/classes.webpush-cr
4
4
 
5
5
  const getDb = () => DcRouterDb.getInstance().getDb();
6
6
 
7
+ // Every index is declared under the exact name the released Web Push schema migration created
8
+ // it with (`ts_migrations/index.ts` via `ts/webpush/webpush-indexes.ts`), so model initialization
9
+ // verifies the existing topology instead of installing a second, differently named index over the
10
+ // same key. `test/test.webpush-persisted-bytes.node.ts` keeps both declarations in lockstep.
11
+ @plugins.smartdata.namedIndex({
12
+ name: 'web_push_spool_id_unique',
13
+ key: { id: 1 },
14
+ options: { unique: true },
15
+ })
16
+ @plugins.smartdata.namedIndex({
17
+ name: 'web_push_spool_idempotency_unique',
18
+ key: { bindingId: 1, lifecycleGeneration: 1, credentialId: 1, idempotencyKeyDigest: 1 },
19
+ options: { unique: true },
20
+ })
21
+ @plugins.smartdata.namedIndex({
22
+ name: 'web_push_spool_delivery_scan',
23
+ key: { state: 1, nextAttemptAt: 1, leaseExpiresAt: 1 },
24
+ })
25
+ @plugins.smartdata.namedIndex({
26
+ name: 'web_push_spool_cancellation_scan',
27
+ key: { bindingId: 1, lifecycleGeneration: 1, subscriptionId: 1, state: 1 },
28
+ })
29
+ @plugins.smartdata.namedIndex({
30
+ name: 'web_push_spool_cancelled_lease_scan',
31
+ key: { state: 1, cancelRequestedAt: 1, leaseExpiresAt: 1, id: 1 },
32
+ })
33
+ @plugins.smartdata.namedIndex({
34
+ name: 'web_push_spool_lifecycle_drain_scan',
35
+ key: { bindingId: 1, lifecycleGeneration: 1, state: 1, leaseExpiresAt: 1 },
36
+ })
37
+ @plugins.smartdata.namedIndex({
38
+ name: 'web_push_spool_vapid_retirement_scan',
39
+ key: { bindingId: 1, lifecycleGeneration: 1, vapidKeyId: 1, state: 1 },
40
+ })
41
+ @plugins.smartdata.namedIndex({
42
+ name: 'web_push_spool_terminal_ttl',
43
+ key: { purgeAt: 1 },
44
+ options: { expireAfterSeconds: 0 },
45
+ })
7
46
  @plugins.smartdata.Collection(() => getDb())
8
47
  export class WebPushSpoolDoc extends plugins.smartdata.SmartDataDbDoc<
9
48
  WebPushSpoolDoc,
@@ -106,11 +145,6 @@ export class WebPushSpoolDoc extends plugins.smartdata.SmartDataDbDoc<
106
145
  @plugins.smartdata.svDb()
107
146
  public purgeAt?: Date;
108
147
 
109
- public static getNativeCollection():
110
- plugins.smartdata.SmartdataCollection<WebPushSpoolDoc>['mongoDbCollection'] {
111
- return getDb().mongoDb.collection('WebPushSpoolDoc');
112
- }
113
-
114
148
  public override async createSavableObject(): Promise<WebPushSpoolDoc> {
115
149
  const savable = await super.createSavableObject();
116
150
  for (const field of [
@@ -1501,7 +1501,9 @@ export class DnsManager {
1501
1501
  rec.providerRecordId = undefined;
1502
1502
  rec.updatedAt = now;
1503
1503
  await rec.save({ session });
1504
- await db.mongoDb.collection('DnsRecordDoc').updateOne(
1504
+ // save() persists set fields only, so the provider link is removed with an
1505
+ // explicit atomic $unset inside the same transaction.
1506
+ await DnsRecordDoc.atomicUpdate<DnsRecordDoc>(
1505
1507
  { id: rec.id },
1506
1508
  { $unset: { providerRecordId: '' } },
1507
1509
  { session },
@@ -1520,7 +1522,9 @@ export class DnsManager {
1520
1522
  domain.updatedAt = now;
1521
1523
  domain.authoritative = ownership.verified;
1522
1524
  await domain.save({ session });
1523
- await db.mongoDb.collection('DomainDoc').updateOne(
1525
+ // Same reason as above: the provider-managed fields must disappear from the
1526
+ // stored document, which only an atomic $unset expresses.
1527
+ await DomainDoc.atomicUpdate<DomainDoc>(
1524
1528
  { id: domain.id },
1525
1529
  {
1526
1530
  $unset: {