arcane-os 0.2.2 → 0.3.0
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/CHANGELOG.md +29 -0
- package/README.md +8 -8
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
- package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
- package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
- package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
- package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
- package/browser-runtime/ai/browser-speech.mjs +2 -0
- package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
- package/browser-runtime/ai/model-controller.mjs +285 -95
- package/browser-runtime/ai/speech-worker-client.mjs +247 -32
- package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
- package/browser-runtime/event-manager.mjs +1097 -1
- package/docs/architecture.md +2 -2
- package/docs/event-manager.md +155 -27
- package/docs/reference/README.md +27 -27
- package/docs/reference/ai/browser-speech-package-authority.json +835 -0
- package/docs/reference/ai/browser-speech.md +1162 -246
- package/docs/reference/ai/browser-wasm.md +18 -7
- package/docs/reference/availability-and-normalization.md +6 -3
- package/docs/reference/behavioral-testing.md +29 -6
- package/docs/reference/cli.md +117 -9
- package/docs/reference/core/arcane-ai-contracts.md +1 -1
- package/docs/reference/event-manager.md +577 -32
- package/docs/reference/inventory/package-api.json +478 -2
- package/docs/reference/inventory/runtime-components.json +108 -44
- package/docs/reference/inventory/runtime-modules.json +131 -53
- package/docs/reference/mail.md +316 -0
- package/docs/reference/protocols.md +157 -43
- package/docs/reference/runtime-components.md +258 -83
- package/docs/reference/runtime-modules.md +613 -77
- package/docs/reference/sdk-api.md +1014 -25
- package/package.json +5 -4
- package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
- package/runtime/arcane/components/app-bar.html +34 -13
- package/runtime/arcane/components/assistant-panel.html +110 -57
- package/runtime/arcane/components/calculator.html +7 -4
- package/runtime/arcane/components/chart.html +58 -17
- package/runtime/arcane/components/chat.html +606 -136
- package/runtime/arcane/components/conversation-view.html +13 -6
- package/runtime/arcane/components/dashboard-config.html +96 -59
- package/runtime/arcane/components/data-maintenance.html +69 -14
- package/runtime/arcane/components/data-view.html +53 -7
- package/runtime/arcane/components/directory-picker.html +118 -32
- package/runtime/arcane/components/document-inspector.html +47 -10
- package/runtime/arcane/components/file-drop.html +81 -35
- package/runtime/arcane/components/file-inspector.html +72 -22
- package/runtime/arcane/components/file-manager.html +374 -79
- package/runtime/arcane/components/integration-settings.html +12 -5
- package/runtime/arcane/components/local-ai-status.html +48 -19
- package/runtime/arcane/components/markdown-document.html +161 -68
- package/runtime/arcane/components/markdown-editor.html +110 -33
- package/runtime/arcane/components/media-embed.html +8 -5
- package/runtime/arcane/components/modal.html +15 -5
- package/runtime/arcane/components/output-panel.html +28 -23
- package/runtime/arcane/components/preferences-form.html +22 -4
- package/runtime/arcane/components/record-timeline.html +18 -2
- package/runtime/arcane/components/relationship-board.html +23 -3
- package/runtime/arcane/components/screen-capture.html +10 -4
- package/runtime/arcane/components/source-code-viewer.html +76 -9
- package/runtime/arcane/components/source-explanation.html +23 -3
- package/runtime/arcane/components/speech.html +462 -384
- package/runtime/arcane/components/summary-strip.html +22 -11
- package/runtime/arcane/components/table.html +39 -21
- package/runtime/arcane/components/task-progress.html +79 -21
- package/runtime/arcane/components/terminal-workspace.html +7 -4
- package/runtime/arcane/components/theme-editor.html +7 -3
- package/runtime/arcane/components/unified-inbox.html +9 -4
- package/runtime/arcane/components/voice-transcription.html +639 -98
- package/runtime/arcane/components/weather-widget.html +5 -3
- package/runtime/arcane/components/web-navigator.html +48 -8
- package/runtime/arcane/entities/Chat.js +1 -1
- package/runtime/arcane/entities/User.js +110 -23
- package/runtime/arcane/modules/AI.js +2109 -130
- package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
- package/runtime/arcane/modules/AIRuntimeState.js +109 -52
- package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
- package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
- package/runtime/arcane/modules/CalculatorEngine.js +63 -3
- package/runtime/arcane/modules/CommunicationAppController.js +588 -28
- package/runtime/arcane/modules/CommunicationHub.js +590 -10
- package/runtime/arcane/modules/ComponentContracts.js +470 -0
- package/runtime/arcane/modules/ConversationTimebox.js +152 -33
- package/runtime/arcane/modules/DBLS.js +40 -7
- package/runtime/arcane/modules/DBOPFS.js +35 -11
- package/runtime/arcane/modules/DataMaintenance.js +12 -2
- package/runtime/arcane/modules/Errors.js +65 -7
- package/runtime/arcane/modules/HTMLImport.js +198 -14
- package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
- package/runtime/arcane/modules/Mail.js +738 -115
- package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
- package/runtime/arcane/modules/MailTransport.mjs +197 -39
- package/runtime/arcane/modules/Ollama.js +36 -1
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
- package/runtime/arcane/modules/PreferenceStore.js +367 -33
- package/runtime/arcane/modules/RecordReviewStore.js +322 -23
- package/runtime/arcane/modules/ScreenCapture.js +1397 -15
- package/runtime/arcane/modules/SpeechPlayback.js +438 -41
- package/runtime/arcane/modules/TerminalClient.js +277 -12
- package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
- package/runtime/arcane/modules/ThemeManager.js +39 -7
- package/runtime/arcane/modules/TimeGuard.js +131 -20
- package/runtime/arcane/modules/WaitForComponent.js +386 -33
- package/schemas/arcane-lock.schema.json +2 -2
- package/src/cli/main.mjs +435 -9
- package/src/event-manager.mjs +1097 -1
- package/src/import-map.mjs +21 -3
- package/src/index.mjs +13 -0
- package/src/installed-sdk-runtime.mjs +112 -0
- package/src/mail-api.mjs +22 -0
- package/src/mail-credentials.mjs +667 -0
- package/src/mail-server.mjs +1769 -0
- package/src/mail.mjs +261 -0
- package/src/sdk-browser-runtime.mjs +85 -41
- package/src/testing-loader.mjs +7 -0
- package/src/toolchain.mjs +3 -0
- package/src/workspace.mjs +1 -1
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
export const MAIL_OUTBOX_PROTOCOL='arcane-mail-outbox/1';
|
|
2
|
+
export const MAIL_OUTBOX_TABLE='mail_outbox';
|
|
3
|
+
export const MAIL_OUTBOX_IDEMPOTENCY_WINDOW_MS=24*60*60*1000;
|
|
4
|
+
export const MAIL_OUTBOX_STATES=Object.freeze([
|
|
5
|
+
'queued',
|
|
6
|
+
'sending',
|
|
7
|
+
'retry_wait',
|
|
8
|
+
'accepted',
|
|
9
|
+
'failed',
|
|
10
|
+
'reconciliation_required'
|
|
11
|
+
]);
|
|
12
|
+
export const MAIL_OUTBOX_ACCEPTANCE_AUTHORITIES=Object.freeze([
|
|
13
|
+
'arcane-core-mail-send-v1'
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const STATE_SET=new Set(MAIL_OUTBOX_STATES);
|
|
17
|
+
const ACCEPTANCE_AUTHORITY_SET=new Set(MAIL_OUTBOX_ACCEPTANCE_AUTHORITIES);
|
|
18
|
+
const REPORT_KEY_PATTERN=/^[A-Za-z0-9._:-]{8,128}$/;
|
|
19
|
+
const SAFE_CODE_PATTERN=/^[A-Za-z0-9._:-]{1,128}$/;
|
|
20
|
+
const SAFE_ID_PATTERN=/^[A-Za-z0-9._:-]{1,256}$/;
|
|
21
|
+
const TABLE_PATTERN=/^[a-z][a-z0-9_]{0,63}$/;
|
|
22
|
+
const FILE_SUFFIX='.mail-outbox.json';
|
|
23
|
+
const QUARANTINE_PROTOCOL='arcane-mail-outbox-quarantine/1';
|
|
24
|
+
const QUARANTINE_TABLE='mail_outbox_quarantine';
|
|
25
|
+
const DEFAULT_MAX_RECORDS=512;
|
|
26
|
+
const DEFAULT_MAX_INVALID_RECORDS=128;
|
|
27
|
+
const DEFAULT_MAX_ATTEMPTS_PER_DRAIN=16;
|
|
28
|
+
const DEFAULT_MAX_REPORT_BYTES=786_432;
|
|
29
|
+
const MAX_MAINTENANCE_BATCH=64;
|
|
30
|
+
const MAX_RETRY_AFTER_SECONDS=24*60*60;
|
|
31
|
+
const TABLE_LOCK_OPTIONS=Object.freeze({mode:'exclusive'});
|
|
32
|
+
const STRUCTURAL_RECORD_ERROR_CODES=new Set([
|
|
33
|
+
'MAIL_OUTBOX_RECORD_INVALID',
|
|
34
|
+
'MAIL_OUTBOX_REPORT_KEY_INVALID'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function coded(error,code){
|
|
38
|
+
if(!error.code) error.code=code;
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function reportMailOutboxObserverError(error){
|
|
43
|
+
try{
|
|
44
|
+
if(typeof globalThis.reportError==='function'){
|
|
45
|
+
globalThis.reportError(error);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}catch{}
|
|
49
|
+
try{globalThis.console?.error?.(error);}catch{}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fail(message,code='MAIL_OUTBOX_INVALID',ErrorType=Error,cause){
|
|
53
|
+
const options=cause===undefined?undefined:{cause};
|
|
54
|
+
throw coded(new ErrorType(message,options),code);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPlainRecord(value){
|
|
58
|
+
return Boolean(value)
|
|
59
|
+
&&typeof value==='object'
|
|
60
|
+
&&!Array.isArray(value)
|
|
61
|
+
&&Object.getPrototypeOf(value)===Object.prototype;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function positiveInteger(value,label,defaultValue,maximum){
|
|
65
|
+
const resolved=value===undefined?defaultValue:value;
|
|
66
|
+
if(!Number.isSafeInteger(resolved)||resolved<1||resolved>maximum){
|
|
67
|
+
fail(`${label} must be an integer from 1 through ${maximum}.`);
|
|
68
|
+
}
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function timestamp(value,label,{nullable=false}={}){
|
|
73
|
+
if(nullable&&value===null) return null;
|
|
74
|
+
if(!Number.isSafeInteger(value)||value<0){
|
|
75
|
+
fail(`${label} must be a nonnegative epoch-millisecond integer.`,'MAIL_OUTBOX_RECORD_INVALID');
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clockMilliseconds(clock){
|
|
81
|
+
const value=clock();
|
|
82
|
+
const milliseconds=value instanceof Date?value.getTime():Number(value);
|
|
83
|
+
if(!Number.isSafeInteger(milliseconds)||milliseconds<0){
|
|
84
|
+
fail('Mail outbox clock returned an invalid time.','MAIL_OUTBOX_CLOCK_INVALID');
|
|
85
|
+
}
|
|
86
|
+
return milliseconds;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function safeString(value,pattern){
|
|
90
|
+
return typeof value==='string'&&pattern.test(value)?value:null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeStatusCode(value){
|
|
94
|
+
return Number.isSafeInteger(value)&&value>=100&&value<=599?value:null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function retryAfterSeconds(value){
|
|
98
|
+
if(value===undefined||value===null) return null;
|
|
99
|
+
const seconds=Number(value);
|
|
100
|
+
if(!Number.isSafeInteger(seconds)||seconds<0||seconds>MAX_RETRY_AFTER_SECONDS){
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return seconds;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function deepFreeze(value,seen=new Set()){
|
|
107
|
+
if(value===null||(typeof value!=='object'&&typeof value!=='function')||seen.has(value)){
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
seen.add(value);
|
|
111
|
+
for(const key of Reflect.ownKeys(value)) deepFreeze(value[key],seen);
|
|
112
|
+
return Object.freeze(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseSerializedReport(value){
|
|
116
|
+
if(typeof value!=='string'||!value){
|
|
117
|
+
fail('Mail outbox record contains no serialized report.','MAIL_OUTBOX_RECORD_INVALID');
|
|
118
|
+
}
|
|
119
|
+
let parsed;
|
|
120
|
+
try{
|
|
121
|
+
parsed=JSON.parse(value);
|
|
122
|
+
}catch(error){
|
|
123
|
+
fail(
|
|
124
|
+
'Mail outbox record contains invalid serialized JSON.',
|
|
125
|
+
'MAIL_OUTBOX_RECORD_INVALID',
|
|
126
|
+
Error,
|
|
127
|
+
error
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if(!isPlainRecord(parsed)){
|
|
131
|
+
fail('Mail outbox serialized report must contain a JSON object.','MAIL_OUTBOX_RECORD_INVALID');
|
|
132
|
+
}
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function serializeReport(report,maxReportBytes){
|
|
137
|
+
if(!isPlainRecord(report)){
|
|
138
|
+
fail('Mail outbox report must be a plain object.','MAIL_OUTBOX_REPORT_INVALID',TypeError);
|
|
139
|
+
}
|
|
140
|
+
let serialized;
|
|
141
|
+
try{
|
|
142
|
+
serialized=JSON.stringify(report);
|
|
143
|
+
}catch(error){
|
|
144
|
+
fail('Mail outbox report must be JSON serializable.','MAIL_OUTBOX_REPORT_INVALID',TypeError,error);
|
|
145
|
+
}
|
|
146
|
+
if(typeof serialized!=='string'||!serialized){
|
|
147
|
+
fail('Mail outbox report must be JSON serializable.','MAIL_OUTBOX_REPORT_INVALID',TypeError);
|
|
148
|
+
}
|
|
149
|
+
if(new TextEncoder().encode(serialized).byteLength>maxReportBytes){
|
|
150
|
+
fail(
|
|
151
|
+
`Mail outbox report cannot exceed ${maxReportBytes} serialized bytes.`,
|
|
152
|
+
'MAIL_OUTBOX_REPORT_TOO_LARGE',
|
|
153
|
+
RangeError
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return serialized;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function quarantineSnapshot(value,maxReportBytes){
|
|
160
|
+
try{
|
|
161
|
+
const serialized=JSON.stringify(value);
|
|
162
|
+
if(typeof serialized==='string'
|
|
163
|
+
&&new TextEncoder().encode(serialized).byteLength<=maxReportBytes){
|
|
164
|
+
return serialized;
|
|
165
|
+
}
|
|
166
|
+
}catch{}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reportKey(value){
|
|
171
|
+
if(typeof value!=='string'||!REPORT_KEY_PATTERN.test(value)){
|
|
172
|
+
fail(
|
|
173
|
+
'Mail outbox reportKey must contain 8-128 safe characters.',
|
|
174
|
+
'MAIL_OUTBOX_REPORT_KEY_INVALID',
|
|
175
|
+
TypeError
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function recordFileName(value){
|
|
182
|
+
return `${reportKey(value)}${FILE_SUFFIX}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function reportKeyFromFileName(value){
|
|
186
|
+
if(typeof value!=='string'||!value.endsWith(FILE_SUFFIX)){
|
|
187
|
+
fail('Mail outbox contains an invalid record filename.','MAIL_OUTBOX_RECORD_INVALID');
|
|
188
|
+
}
|
|
189
|
+
return reportKey(value.slice(0,-FILE_SUFFIX.length));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function invalidRecordMetadata(fileName,error){
|
|
193
|
+
let repairable=false;
|
|
194
|
+
try{
|
|
195
|
+
reportKeyFromFileName(fileName);
|
|
196
|
+
repairable=true;
|
|
197
|
+
}catch{}
|
|
198
|
+
return Object.freeze({
|
|
199
|
+
fileName:typeof fileName==='string'?fileName:'',
|
|
200
|
+
code:safeString(error?.code,SAFE_CODE_PATTERN)||'MAIL_OUTBOX_RECORD_UNREADABLE',
|
|
201
|
+
repairable
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function structuralRecordError(error){
|
|
206
|
+
return STRUCTURAL_RECORD_ERROR_CODES.has(error?.code);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizedFailure(value){
|
|
210
|
+
if(value===null) return null;
|
|
211
|
+
if(!isPlainRecord(value)){
|
|
212
|
+
fail('Mail outbox failure record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
213
|
+
}
|
|
214
|
+
const code=safeString(value.code,SAFE_CODE_PATTERN);
|
|
215
|
+
if(!code||typeof value.retryable!=='boolean'||typeof value.uncertain!=='boolean'){
|
|
216
|
+
fail('Mail outbox failure record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
217
|
+
}
|
|
218
|
+
return Object.freeze({
|
|
219
|
+
code,
|
|
220
|
+
statusCode:safeStatusCode(value.statusCode),
|
|
221
|
+
retryable:value.retryable,
|
|
222
|
+
uncertain:value.uncertain,
|
|
223
|
+
retryAfterSeconds:retryAfterSeconds(value.retryAfterSeconds)
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizedResult(value,{invalidCode='MAIL_OUTBOX_RECORD_INVALID'}={}){
|
|
228
|
+
if(value===null) return null;
|
|
229
|
+
if(!isPlainRecord(value)){
|
|
230
|
+
fail('Mail outbox delivery result is invalid.',invalidCode);
|
|
231
|
+
}
|
|
232
|
+
const status=safeString(value.status,SAFE_CODE_PATTERN);
|
|
233
|
+
if(!status){
|
|
234
|
+
fail('Mail outbox delivery result is invalid.',invalidCode);
|
|
235
|
+
}
|
|
236
|
+
const acceptanceAuthority=safeString(value.acceptanceAuthority,SAFE_CODE_PATTERN);
|
|
237
|
+
if(value.acceptanceAuthority!==null
|
|
238
|
+
&&value.acceptanceAuthority!==undefined
|
|
239
|
+
&&acceptanceAuthority===null){
|
|
240
|
+
fail('Mail outbox delivery result has a malformed acceptance authority.',invalidCode);
|
|
241
|
+
}
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
status,
|
|
244
|
+
classification:safeString(value.classification,SAFE_CODE_PATTERN),
|
|
245
|
+
acceptanceAuthority,
|
|
246
|
+
requestId:safeString(value.requestId,SAFE_ID_PATTERN),
|
|
247
|
+
providerId:safeString(value.providerId,SAFE_ID_PATTERN),
|
|
248
|
+
providerStatus:safeString(value.providerStatus,SAFE_CODE_PATTERN),
|
|
249
|
+
providerCode:safeString(value.providerCode,SAFE_CODE_PATTERN),
|
|
250
|
+
statusCode:safeStatusCode(value.statusCode),
|
|
251
|
+
retryAfterSeconds:retryAfterSeconds(value.retryAfterSeconds)
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function hasAcceptedEvidence(result){
|
|
256
|
+
return Boolean(result?.requestId&&(
|
|
257
|
+
result.providerId||ACCEPTANCE_AUTHORITY_SET.has(result.acceptanceAuthority)
|
|
258
|
+
));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function validateRecordState(record){
|
|
262
|
+
const attempted=record.attempts>0
|
|
263
|
+
&&record.firstAttemptAt!==null
|
|
264
|
+
&&record.lastAttemptAt!==null;
|
|
265
|
+
if(record.lastAttemptAt!==null&&record.updatedAt<record.lastAttemptAt){
|
|
266
|
+
fail('Mail outbox record was updated before its last attempt.','MAIL_OUTBOX_RECORD_INVALID');
|
|
267
|
+
}
|
|
268
|
+
if(record.nextAttemptAt!==null&&(
|
|
269
|
+
record.firstAttemptAt===null||record.nextAttemptAt<record.firstAttemptAt
|
|
270
|
+
)){
|
|
271
|
+
fail('Mail outbox record contains an invalid retry time.','MAIL_OUTBOX_RECORD_INVALID');
|
|
272
|
+
}
|
|
273
|
+
if(record.result?.acceptanceAuthority!=null&&(
|
|
274
|
+
record.state!=='accepted'
|
|
275
|
+
||!ACCEPTANCE_AUTHORITY_SET.has(record.result.acceptanceAuthority)
|
|
276
|
+
)){
|
|
277
|
+
fail('Mail outbox record contains an invalid acceptance authority.','MAIL_OUTBOX_RECORD_INVALID');
|
|
278
|
+
}
|
|
279
|
+
switch(record.state){
|
|
280
|
+
case 'queued':
|
|
281
|
+
if(record.attempts!==0
|
|
282
|
+
||record.firstAttemptAt!==null
|
|
283
|
+
||record.lastAttemptAt!==null
|
|
284
|
+
||record.nextAttemptAt!==null
|
|
285
|
+
||record.result!==null
|
|
286
|
+
||record.failure!==null){
|
|
287
|
+
fail('Queued mail outbox record contains attempt state.','MAIL_OUTBOX_RECORD_INVALID');
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
case 'sending':
|
|
291
|
+
if(!attempted
|
|
292
|
+
||record.nextAttemptAt!==null
|
|
293
|
+
||record.result!==null
|
|
294
|
+
||record.failure!==null){
|
|
295
|
+
fail('Sending mail outbox record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
case 'retry_wait':
|
|
299
|
+
if(!attempted
|
|
300
|
+
||record.nextAttemptAt===null
|
|
301
|
+
||record.failure?.retryable!==true){
|
|
302
|
+
fail('Retrying mail outbox record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
case 'accepted':
|
|
306
|
+
if(!attempted
|
|
307
|
+
||record.nextAttemptAt!==null
|
|
308
|
+
||record.failure!==null
|
|
309
|
+
||record.result?.status!=='accepted'
|
|
310
|
+
||!hasAcceptedEvidence(record.result)){
|
|
311
|
+
fail('Accepted mail outbox record lacks provider acceptance evidence.','MAIL_OUTBOX_RECORD_INVALID');
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
case 'failed':
|
|
315
|
+
if(!attempted
|
|
316
|
+
||record.nextAttemptAt!==null
|
|
317
|
+
||record.failure===null
|
|
318
|
+
||record.failure.retryable
|
|
319
|
+
||record.failure.uncertain){
|
|
320
|
+
fail('Failed mail outbox record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
case 'reconciliation_required':
|
|
324
|
+
if(!attempted
|
|
325
|
+
||record.nextAttemptAt!==null
|
|
326
|
+
||record.failure?.retryable!==false
|
|
327
|
+
||record.failure.uncertain!==true){
|
|
328
|
+
fail('Mail outbox reconciliation record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
default:
|
|
332
|
+
fail('Mail outbox record contains an invalid state.','MAIL_OUTBOX_RECORD_INVALID');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizedRecord(value,expectedReportKey=null){
|
|
337
|
+
if(!isPlainRecord(value)||value.protocol!==MAIL_OUTBOX_PROTOCOL){
|
|
338
|
+
fail('Mail outbox record is invalid.','MAIL_OUTBOX_RECORD_INVALID');
|
|
339
|
+
}
|
|
340
|
+
const key=reportKey(value.reportKey);
|
|
341
|
+
if(expectedReportKey!==null&&key!==expectedReportKey){
|
|
342
|
+
fail('Mail outbox record identity does not match its filename.','MAIL_OUTBOX_RECORD_INVALID');
|
|
343
|
+
}
|
|
344
|
+
parseSerializedReport(value.serializedReport);
|
|
345
|
+
if(!STATE_SET.has(value.state)){
|
|
346
|
+
fail('Mail outbox record contains an invalid state.','MAIL_OUTBOX_RECORD_INVALID');
|
|
347
|
+
}
|
|
348
|
+
if(!Number.isSafeInteger(value.attempts)||value.attempts<0){
|
|
349
|
+
fail('Mail outbox record contains an invalid attempt count.','MAIL_OUTBOX_RECORD_INVALID');
|
|
350
|
+
}
|
|
351
|
+
const record={
|
|
352
|
+
protocol:MAIL_OUTBOX_PROTOCOL,
|
|
353
|
+
reportKey:key,
|
|
354
|
+
serializedReport:value.serializedReport,
|
|
355
|
+
state:value.state,
|
|
356
|
+
createdAt:timestamp(value.createdAt,'createdAt'),
|
|
357
|
+
updatedAt:timestamp(value.updatedAt,'updatedAt'),
|
|
358
|
+
firstAttemptAt:timestamp(value.firstAttemptAt,'firstAttemptAt',{nullable:true}),
|
|
359
|
+
lastAttemptAt:timestamp(value.lastAttemptAt,'lastAttemptAt',{nullable:true}),
|
|
360
|
+
nextAttemptAt:timestamp(value.nextAttemptAt,'nextAttemptAt',{nullable:true}),
|
|
361
|
+
attempts:value.attempts,
|
|
362
|
+
result:normalizedResult(value.result),
|
|
363
|
+
failure:normalizedFailure(value.failure)
|
|
364
|
+
};
|
|
365
|
+
if(record.updatedAt<record.createdAt
|
|
366
|
+
||(record.firstAttemptAt!==null&&record.firstAttemptAt<record.createdAt)
|
|
367
|
+
||(record.lastAttemptAt!==null&&record.firstAttemptAt===null)
|
|
368
|
+
||(record.lastAttemptAt!==null&&record.lastAttemptAt<record.firstAttemptAt)){
|
|
369
|
+
fail('Mail outbox record contains inconsistent timestamps.','MAIL_OUTBOX_RECORD_INVALID');
|
|
370
|
+
}
|
|
371
|
+
validateRecordState(record);
|
|
372
|
+
return deepFreeze(record);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function updatedRecord(record,patch){
|
|
376
|
+
return normalizedRecord({...record,...patch},record.reportKey);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function deliveryRequest(record,signal){
|
|
380
|
+
const report=deepFreeze(parseSerializedReport(record.serializedReport));
|
|
381
|
+
return Object.freeze({
|
|
382
|
+
report,
|
|
383
|
+
reportKey:record.reportKey,
|
|
384
|
+
serializedReport:record.serializedReport,
|
|
385
|
+
signal
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function deliveryResult(value){
|
|
390
|
+
if(!isPlainRecord(value)){
|
|
391
|
+
fail(
|
|
392
|
+
'Mail delivery returned an invalid result.',
|
|
393
|
+
'MAIL_OUTBOX_DELIVERY_RESULT_INVALID'
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
const result=normalizedResult(value,{invalidCode:'MAIL_OUTBOX_DELIVERY_RESULT_INVALID'});
|
|
397
|
+
if(!result.requestId){
|
|
398
|
+
fail('Mail delivery result has no valid requestId.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
399
|
+
}
|
|
400
|
+
if(result.acceptanceAuthority!==null&&(
|
|
401
|
+
result.status!=='accepted'
|
|
402
|
+
||!ACCEPTANCE_AUTHORITY_SET.has(result.acceptanceAuthority)
|
|
403
|
+
)){
|
|
404
|
+
fail('Mail delivery result has an invalid acceptance authority.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
405
|
+
}
|
|
406
|
+
switch(result.status){
|
|
407
|
+
case 'accepted':
|
|
408
|
+
if(result.classification!==null&&result.classification!=='accepted'){
|
|
409
|
+
fail('Accepted mail delivery has an invalid classification.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
410
|
+
}
|
|
411
|
+
if(!hasAcceptedEvidence(result)){
|
|
412
|
+
fail(
|
|
413
|
+
'Accepted mail delivery has neither a valid providerId nor an admitted acceptance authority.',
|
|
414
|
+
'MAIL_OUTBOX_DELIVERY_RESULT_INVALID'
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return Object.freeze({kind:'accepted',result,failure:null});
|
|
418
|
+
case 'delivery_uncertain':
|
|
419
|
+
if(result.classification!==null&&result.classification!=='ambiguous'){
|
|
420
|
+
fail('Uncertain mail delivery has an invalid classification.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
421
|
+
}
|
|
422
|
+
return Object.freeze({
|
|
423
|
+
kind:'retry',
|
|
424
|
+
result,
|
|
425
|
+
failure:Object.freeze({
|
|
426
|
+
code:result.providerCode||'MAIL_DELIVERY_UNCERTAIN',
|
|
427
|
+
statusCode:result.statusCode,
|
|
428
|
+
retryable:true,
|
|
429
|
+
uncertain:true,
|
|
430
|
+
retryAfterSeconds:result.retryAfterSeconds
|
|
431
|
+
})
|
|
432
|
+
});
|
|
433
|
+
case 'retryable':
|
|
434
|
+
if(result.classification!==null&&result.classification!=='retryable'){
|
|
435
|
+
fail('Retryable mail delivery has an invalid classification.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
436
|
+
}
|
|
437
|
+
return Object.freeze({
|
|
438
|
+
kind:'retry',
|
|
439
|
+
result,
|
|
440
|
+
failure:Object.freeze({
|
|
441
|
+
code:result.providerCode||'MAIL_DELIVERY_RETRYABLE',
|
|
442
|
+
statusCode:result.statusCode,
|
|
443
|
+
retryable:true,
|
|
444
|
+
uncertain:false,
|
|
445
|
+
retryAfterSeconds:result.retryAfterSeconds
|
|
446
|
+
})
|
|
447
|
+
});
|
|
448
|
+
case 'permanently_rejected':
|
|
449
|
+
case 'partially_accepted':
|
|
450
|
+
if(result.classification!==null&&result.classification!=='permanent'){
|
|
451
|
+
fail('Rejected mail delivery has an invalid classification.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
452
|
+
}
|
|
453
|
+
return Object.freeze({
|
|
454
|
+
kind:'failed',
|
|
455
|
+
result,
|
|
456
|
+
failure:Object.freeze({
|
|
457
|
+
code:result.providerCode||(
|
|
458
|
+
result.status==='partially_accepted'
|
|
459
|
+
?'MAIL_DELIVERY_PARTIALLY_ACCEPTED'
|
|
460
|
+
:'MAIL_DELIVERY_PERMANENTLY_REJECTED'
|
|
461
|
+
),
|
|
462
|
+
statusCode:result.statusCode,
|
|
463
|
+
retryable:false,
|
|
464
|
+
uncertain:false,
|
|
465
|
+
retryAfterSeconds:null
|
|
466
|
+
})
|
|
467
|
+
});
|
|
468
|
+
default:
|
|
469
|
+
fail('Mail delivery returned an unsupported status.','MAIL_OUTBOX_DELIVERY_RESULT_INVALID');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function deliveryFailure(error){
|
|
474
|
+
const classification=safeString(error?.classification,SAFE_CODE_PATTERN);
|
|
475
|
+
const cancelled=error?.name==='AbortError'
|
|
476
|
+
||error?.code==='MAIL_OUTBOX_ABORTED'
|
|
477
|
+
||error?.code==='MAIL_CANCELLED';
|
|
478
|
+
const uncertain=cancelled||error?.uncertain===true||classification==='ambiguous';
|
|
479
|
+
const retryable=uncertain||error?.retryable===true||classification==='retryable';
|
|
480
|
+
const milliseconds=Number(error?.retryAfterMs);
|
|
481
|
+
const seconds=retryAfterSeconds(
|
|
482
|
+
error?.retryAfterSeconds??(
|
|
483
|
+
Number.isFinite(milliseconds)&&milliseconds>=0
|
|
484
|
+
?Math.ceil(milliseconds/1000)
|
|
485
|
+
:null
|
|
486
|
+
)
|
|
487
|
+
);
|
|
488
|
+
return Object.freeze({
|
|
489
|
+
kind:retryable?'retry':'failed',
|
|
490
|
+
result:null,
|
|
491
|
+
failure:Object.freeze({
|
|
492
|
+
code:safeString(error?.code,SAFE_CODE_PATTERN)||'MAIL_DELIVERY_FAILED',
|
|
493
|
+
statusCode:safeStatusCode(error?.statusCode),
|
|
494
|
+
retryable,
|
|
495
|
+
uncertain,
|
|
496
|
+
retryAfterSeconds:seconds
|
|
497
|
+
})
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function abortError(signal){
|
|
502
|
+
if(!signal?.aborted) return null;
|
|
503
|
+
const error=new Error('Mail outbox operation was aborted.',{
|
|
504
|
+
cause:signal.reason
|
|
505
|
+
});
|
|
506
|
+
error.name='AbortError';
|
|
507
|
+
return coded(error,'MAIL_OUTBOX_ABORTED');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function throwIfAborted(signal){
|
|
511
|
+
const error=abortError(signal);
|
|
512
|
+
if(error) throw error;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function abortedDeliveryError(error,signal){
|
|
516
|
+
if(!signal?.aborted
|
|
517
|
+
&&error?.name!=='AbortError'
|
|
518
|
+
&&error?.code!=='MAIL_OUTBOX_ABORTED'
|
|
519
|
+
&&error?.code!=='MAIL_CANCELLED') return null;
|
|
520
|
+
return abortError(signal)??coded(
|
|
521
|
+
new Error('Mail outbox delivery was aborted.',{cause:error}),
|
|
522
|
+
'MAIL_OUTBOX_ABORTED'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function validateSignal(signal){
|
|
527
|
+
if(signal===null||signal===undefined) return null;
|
|
528
|
+
if(typeof signal!=='object'
|
|
529
|
+
||typeof signal.aborted!=='boolean'
|
|
530
|
+
||typeof signal.addEventListener!=='function'
|
|
531
|
+
||typeof signal.removeEventListener!=='function'){
|
|
532
|
+
fail('Mail outbox signal must be an AbortSignal.','MAIL_OUTBOX_INVALID',TypeError);
|
|
533
|
+
}
|
|
534
|
+
return signal;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function waitForOutboxOperation(operation,signal){
|
|
538
|
+
const normalizedSignal=validateSignal(signal);
|
|
539
|
+
throwIfAborted(normalizedSignal);
|
|
540
|
+
if(!normalizedSignal) return operation;
|
|
541
|
+
return new Promise(function observeOutboxCallerAbort(resolve,reject){
|
|
542
|
+
let settled=false;
|
|
543
|
+
function finish(callback,value){
|
|
544
|
+
if(settled) return;
|
|
545
|
+
settled=true;
|
|
546
|
+
normalizedSignal.removeEventListener('abort',handleAbort);
|
|
547
|
+
callback(value);
|
|
548
|
+
}
|
|
549
|
+
function handleAbort(){
|
|
550
|
+
try{
|
|
551
|
+
throwIfAborted(normalizedSignal);
|
|
552
|
+
}catch(error){
|
|
553
|
+
finish(reject,error);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
normalizedSignal.addEventListener('abort',handleAbort,{once:true});
|
|
557
|
+
Promise.resolve(operation).then(
|
|
558
|
+
finish.bind(null,resolve),
|
|
559
|
+
finish.bind(null,reject)
|
|
560
|
+
);
|
|
561
|
+
if(normalizedSignal.aborted) handleAbort();
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function drainReason(value){
|
|
566
|
+
if(typeof value!=='string'||!/^[a-z][a-z0-9_-]{0,63}$/.test(value)){
|
|
567
|
+
fail('Mail outbox drain reason is invalid.','MAIL_OUTBOX_INVALID',TypeError);
|
|
568
|
+
}
|
|
569
|
+
return value;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function terminalState(state){
|
|
573
|
+
return state==='accepted'||state==='failed'||state==='reconciliation_required';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function summary(reason,online,records,attempted,invalidRecords,bounded){
|
|
577
|
+
const counts={
|
|
578
|
+
queued:0,
|
|
579
|
+
sending:0,
|
|
580
|
+
retry_wait:0,
|
|
581
|
+
accepted:0,
|
|
582
|
+
failed:0,
|
|
583
|
+
reconciliation_required:0
|
|
584
|
+
};
|
|
585
|
+
for(const record of records) counts[record.state]+=1;
|
|
586
|
+
return deepFreeze({
|
|
587
|
+
reason,
|
|
588
|
+
online,
|
|
589
|
+
considered:records.length,
|
|
590
|
+
attempted,
|
|
591
|
+
invalidRecords,
|
|
592
|
+
bounded,
|
|
593
|
+
pending:counts.queued+counts.sending+counts.retry_wait,
|
|
594
|
+
states:counts
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
class MailOutbox{
|
|
599
|
+
#clock;
|
|
600
|
+
#deliver;
|
|
601
|
+
#drainLockName;
|
|
602
|
+
#drainPromise=null;
|
|
603
|
+
#enqueuePromise=null;
|
|
604
|
+
#isOnline;
|
|
605
|
+
#invalidRecords=Object.freeze([]);
|
|
606
|
+
#lastBackgroundError=null;
|
|
607
|
+
#lockManager;
|
|
608
|
+
#lockName;
|
|
609
|
+
#maxAttemptsPerDrain;
|
|
610
|
+
#maxInvalidRecords;
|
|
611
|
+
#maxRecords;
|
|
612
|
+
#maxReportBytes;
|
|
613
|
+
#onlineHandler;
|
|
614
|
+
#onlineController=null;
|
|
615
|
+
#onlineTarget;
|
|
616
|
+
#onRecordCommitted;
|
|
617
|
+
#quarantineTable;
|
|
618
|
+
#started=false;
|
|
619
|
+
#storage;
|
|
620
|
+
#table;
|
|
621
|
+
|
|
622
|
+
constructor({
|
|
623
|
+
storage,
|
|
624
|
+
deliver,
|
|
625
|
+
clock=Date.now,
|
|
626
|
+
isOnline=function defaultOnlineStatus(){
|
|
627
|
+
return globalThis.navigator?.onLine!==false;
|
|
628
|
+
},
|
|
629
|
+
lockManager=undefined,
|
|
630
|
+
onlineTarget=typeof globalThis.addEventListener==='function'?globalThis:null,
|
|
631
|
+
onRecordCommitted=null,
|
|
632
|
+
maxAttemptsPerDrain=DEFAULT_MAX_ATTEMPTS_PER_DRAIN,
|
|
633
|
+
maxInvalidRecords=DEFAULT_MAX_INVALID_RECORDS,
|
|
634
|
+
maxRecords=DEFAULT_MAX_RECORDS,
|
|
635
|
+
maxReportBytes=DEFAULT_MAX_REPORT_BYTES,
|
|
636
|
+
quarantineTable=QUARANTINE_TABLE,
|
|
637
|
+
table=MAIL_OUTBOX_TABLE
|
|
638
|
+
}={}){
|
|
639
|
+
if(!storage||typeof storage!=='object'){
|
|
640
|
+
fail('Mail outbox storage is required.','MAIL_OUTBOX_STORAGE_UNAVAILABLE',TypeError);
|
|
641
|
+
}
|
|
642
|
+
for(const method of ['get','set','getAllKeys']){
|
|
643
|
+
if(typeof storage[method]!=='function'){
|
|
644
|
+
fail(
|
|
645
|
+
`Mail outbox storage must provide ${method}().`,
|
|
646
|
+
'MAIL_OUTBOX_STORAGE_UNAVAILABLE',
|
|
647
|
+
TypeError
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const resolvedLockManager=lockManager
|
|
652
|
+
??storage.lockManager
|
|
653
|
+
??globalThis.navigator?.locks;
|
|
654
|
+
if(!resolvedLockManager||typeof resolvedLockManager.request!=='function'){
|
|
655
|
+
fail(
|
|
656
|
+
'Mail outbox requires a Web Locks compatible lock manager.',
|
|
657
|
+
'MAIL_OUTBOX_LOCK_UNAVAILABLE',
|
|
658
|
+
TypeError
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if(typeof deliver!=='function'){
|
|
662
|
+
fail('Mail outbox deliver must be a function.','MAIL_OUTBOX_INVALID',TypeError);
|
|
663
|
+
}
|
|
664
|
+
if(typeof clock!=='function'||typeof isOnline!=='function'){
|
|
665
|
+
fail('Mail outbox clock and isOnline hooks must be functions.','MAIL_OUTBOX_INVALID',TypeError);
|
|
666
|
+
}
|
|
667
|
+
if(onlineTarget!==null&&(
|
|
668
|
+
typeof onlineTarget?.addEventListener!=='function'
|
|
669
|
+
||typeof onlineTarget?.removeEventListener!=='function'
|
|
670
|
+
)){
|
|
671
|
+
fail('Mail outbox onlineTarget must be an EventTarget.','MAIL_OUTBOX_INVALID',TypeError);
|
|
672
|
+
}
|
|
673
|
+
if(onRecordCommitted!==null&&typeof onRecordCommitted!=='function'){
|
|
674
|
+
fail(
|
|
675
|
+
'Mail outbox onRecordCommitted must be a function.',
|
|
676
|
+
'MAIL_OUTBOX_INVALID',
|
|
677
|
+
TypeError
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
if(typeof table!=='string'||!TABLE_PATTERN.test(table)){
|
|
681
|
+
fail('Mail outbox table name is invalid.','MAIL_OUTBOX_INVALID',TypeError);
|
|
682
|
+
}
|
|
683
|
+
if(typeof quarantineTable!=='string'||!TABLE_PATTERN.test(quarantineTable)
|
|
684
|
+
||quarantineTable===table){
|
|
685
|
+
fail('Mail outbox quarantine table name is invalid.','MAIL_OUTBOX_INVALID',TypeError);
|
|
686
|
+
}
|
|
687
|
+
this.#storage=storage;
|
|
688
|
+
this.#deliver=deliver;
|
|
689
|
+
this.#clock=clock;
|
|
690
|
+
this.#isOnline=isOnline;
|
|
691
|
+
this.#lockManager=resolvedLockManager;
|
|
692
|
+
this.#onlineTarget=onlineTarget;
|
|
693
|
+
this.#onRecordCommitted=onRecordCommitted;
|
|
694
|
+
this.#maxAttemptsPerDrain=positiveInteger(
|
|
695
|
+
maxAttemptsPerDrain,
|
|
696
|
+
'maxAttemptsPerDrain',
|
|
697
|
+
DEFAULT_MAX_ATTEMPTS_PER_DRAIN,
|
|
698
|
+
100
|
|
699
|
+
);
|
|
700
|
+
this.#maxInvalidRecords=positiveInteger(
|
|
701
|
+
maxInvalidRecords,
|
|
702
|
+
'maxInvalidRecords',
|
|
703
|
+
DEFAULT_MAX_INVALID_RECORDS,
|
|
704
|
+
10_000
|
|
705
|
+
);
|
|
706
|
+
this.#maxRecords=positiveInteger(maxRecords,'maxRecords',DEFAULT_MAX_RECORDS,10_000);
|
|
707
|
+
this.#maxReportBytes=positiveInteger(
|
|
708
|
+
maxReportBytes,
|
|
709
|
+
'maxReportBytes',
|
|
710
|
+
DEFAULT_MAX_REPORT_BYTES,
|
|
711
|
+
25*1024*1024
|
|
712
|
+
);
|
|
713
|
+
this.#quarantineTable=quarantineTable;
|
|
714
|
+
this.#table=table;
|
|
715
|
+
this.#lockName=`arcane-mail-outbox:${encodeURIComponent(table)}`;
|
|
716
|
+
this.#drainLockName=`${this.#lockName}:drain`;
|
|
717
|
+
this.#onlineHandler=this.#handleOnline.bind(this);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
get started(){return this.#started;}
|
|
721
|
+
get invalidRecords(){return this.#invalidRecords;}
|
|
722
|
+
get lastBackgroundError(){return this.#lastBackgroundError;}
|
|
723
|
+
|
|
724
|
+
async #ready(){
|
|
725
|
+
try{
|
|
726
|
+
if(this.#storage.readyPromise&&typeof this.#storage.readyPromise.then==='function'){
|
|
727
|
+
await this.#storage.readyPromise;
|
|
728
|
+
}
|
|
729
|
+
}catch(error){
|
|
730
|
+
fail('Mail outbox storage failed to become ready.','MAIL_OUTBOX_STORAGE_UNAVAILABLE',Error,error);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
#time(){
|
|
735
|
+
return clockMilliseconds(this.#clock);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
#online(){
|
|
739
|
+
try{
|
|
740
|
+
return this.#isOnline()===true;
|
|
741
|
+
}catch(error){
|
|
742
|
+
this.#lastBackgroundError=coded(
|
|
743
|
+
new Error('Mail outbox online status check failed.',{cause:error}),
|
|
744
|
+
'MAIL_OUTBOX_ONLINE_CHECK_FAILED'
|
|
745
|
+
);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#notifyRecordCommitted(record){
|
|
751
|
+
if(!this.#onRecordCommitted) return;
|
|
752
|
+
try{
|
|
753
|
+
const result=this.#onRecordCommitted(record);
|
|
754
|
+
if(result&&typeof result.then==='function'){
|
|
755
|
+
result.catch(reportMailOutboxObserverError);
|
|
756
|
+
}
|
|
757
|
+
}catch(error){
|
|
758
|
+
reportMailOutboxObserverError(error);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async #withExclusiveLock(lockName,operation,signal=null){
|
|
763
|
+
let acquired=false;
|
|
764
|
+
const options=signal
|
|
765
|
+
?Object.freeze({mode:'exclusive',signal})
|
|
766
|
+
:TABLE_LOCK_OPTIONS;
|
|
767
|
+
try{
|
|
768
|
+
return await this.#lockManager.request(
|
|
769
|
+
lockName,
|
|
770
|
+
options,
|
|
771
|
+
async function runMailOutboxTableMutation(lock){
|
|
772
|
+
if(!lock||lock.mode!=='exclusive'){
|
|
773
|
+
fail(
|
|
774
|
+
'Mail outbox could not acquire its exclusive table lock.',
|
|
775
|
+
'MAIL_OUTBOX_LOCK_UNAVAILABLE'
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
acquired=true;
|
|
779
|
+
return operation();
|
|
780
|
+
}
|
|
781
|
+
);
|
|
782
|
+
}catch(error){
|
|
783
|
+
if(!acquired&&signal?.aborted) throwIfAborted(signal);
|
|
784
|
+
if(acquired||error?.code==='MAIL_OUTBOX_LOCK_UNAVAILABLE') throw error;
|
|
785
|
+
fail(
|
|
786
|
+
'Mail outbox table lock failed.',
|
|
787
|
+
'MAIL_OUTBOX_LOCK_FAILED',
|
|
788
|
+
Error,
|
|
789
|
+
error
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#withTableMutation(operation){
|
|
795
|
+
return this.#withExclusiveLock(this.#lockName,operation);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
#withDrainAuthority(operation,signal){
|
|
799
|
+
return this.#withExclusiveLock(this.#drainLockName,operation,signal);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async #readRawFile(fileName){
|
|
803
|
+
try{
|
|
804
|
+
return await this.#storage.get(this.#table,fileName,true);
|
|
805
|
+
}catch(error){
|
|
806
|
+
fail('Mail outbox could not read durable storage.','MAIL_OUTBOX_STORAGE_FAILED',Error,error);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async #read(key){
|
|
811
|
+
const value=await this.#readRawFile(recordFileName(key));
|
|
812
|
+
return value===null||value===undefined?null:normalizedRecord(value,key);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async #commitNormalizedRecord(normalized){
|
|
816
|
+
try{
|
|
817
|
+
await this.#storage.set(
|
|
818
|
+
this.#table,
|
|
819
|
+
recordFileName(normalized.reportKey),
|
|
820
|
+
normalized
|
|
821
|
+
);
|
|
822
|
+
}catch(error){
|
|
823
|
+
fail('Mail outbox could not write durable storage.','MAIL_OUTBOX_STORAGE_FAILED',Error,error);
|
|
824
|
+
}
|
|
825
|
+
this.#notifyRecordCommitted(normalized);
|
|
826
|
+
return normalized;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async #write(record){
|
|
830
|
+
const normalized=normalizedRecord(record,record.reportKey);
|
|
831
|
+
return this.#withTableMutation(
|
|
832
|
+
this.#commitNormalizedRecord.bind(this,normalized)
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async #keys(){
|
|
837
|
+
let keys;
|
|
838
|
+
try{
|
|
839
|
+
keys=await this.#storage.getAllKeys(this.#table);
|
|
840
|
+
}catch(error){
|
|
841
|
+
fail('Mail outbox could not list durable storage.','MAIL_OUTBOX_STORAGE_FAILED',Error,error);
|
|
842
|
+
}
|
|
843
|
+
if(!Array.isArray(keys)){
|
|
844
|
+
fail('Mail outbox storage returned an invalid key list.','MAIL_OUTBOX_STORAGE_FAILED');
|
|
845
|
+
}
|
|
846
|
+
return [...keys];
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async #inventory(){
|
|
850
|
+
const keys=await this.#keys();
|
|
851
|
+
const scanLimit=this.#maxRecords+this.#maxInvalidRecords;
|
|
852
|
+
const scannedKeys=keys.slice(0,scanLimit);
|
|
853
|
+
const records=[];
|
|
854
|
+
const invalidRecords=[];
|
|
855
|
+
for(const fileName of scannedKeys){
|
|
856
|
+
try{
|
|
857
|
+
const key=reportKeyFromFileName(fileName);
|
|
858
|
+
const value=await this.#readRawFile(fileName);
|
|
859
|
+
records.push(normalizedRecord(value,key));
|
|
860
|
+
}catch(error){
|
|
861
|
+
if(!structuralRecordError(error)) throw error;
|
|
862
|
+
invalidRecords.push(invalidRecordMetadata(fileName,error));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
records.sort(function sortMailOutboxRecords(left,right){
|
|
866
|
+
return left.createdAt-right.createdAt||left.reportKey.localeCompare(right.reportKey,'en');
|
|
867
|
+
});
|
|
868
|
+
const inventory=deepFreeze({
|
|
869
|
+
records,
|
|
870
|
+
invalidRecords,
|
|
871
|
+
totalFiles:keys.length,
|
|
872
|
+
scannedFiles:scannedKeys.length,
|
|
873
|
+
truncated:keys.length>scannedKeys.length,
|
|
874
|
+
overCapacity:records.length>this.#maxRecords
|
|
875
|
+
});
|
|
876
|
+
this.#invalidRecords=inventory.invalidRecords;
|
|
877
|
+
return inventory;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
#assertInvalidTarget(fileName,inventory){
|
|
881
|
+
if(typeof fileName!=='string'||!fileName){
|
|
882
|
+
fail('Mail outbox invalid-record filename is required.','MAIL_OUTBOX_INVALID',TypeError);
|
|
883
|
+
}
|
|
884
|
+
const target=inventory.invalidRecords.find(
|
|
885
|
+
function findInvalidRecord(record){return record.fileName===fileName;}
|
|
886
|
+
);
|
|
887
|
+
if(!target){
|
|
888
|
+
fail(
|
|
889
|
+
'Mail outbox invalid record was not found in the bounded inventory.',
|
|
890
|
+
'MAIL_OUTBOX_INVALID_RECORD_NOT_FOUND'
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
return target;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async #inspectInvalidFile(fileName){
|
|
897
|
+
const keys=await this.#keys();
|
|
898
|
+
if(!keys.includes(fileName)){
|
|
899
|
+
fail(
|
|
900
|
+
'Mail outbox invalid record no longer exists.',
|
|
901
|
+
'MAIL_OUTBOX_INVALID_RECORD_NOT_FOUND'
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
const value=await this.#readRawFile(fileName);
|
|
905
|
+
try{
|
|
906
|
+
const key=reportKeyFromFileName(fileName);
|
|
907
|
+
normalizedRecord(value,key);
|
|
908
|
+
}catch(error){
|
|
909
|
+
if(!structuralRecordError(error)) throw error;
|
|
910
|
+
return Object.freeze({
|
|
911
|
+
metadata:invalidRecordMetadata(fileName,error),
|
|
912
|
+
value
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
fail(
|
|
916
|
+
'Mail outbox invalid record changed before maintenance.',
|
|
917
|
+
'MAIL_OUTBOX_INVALID_RECORD_CHANGED'
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
#assertDeleteAvailable(){
|
|
922
|
+
if(typeof this.#storage.delete!=='function'){
|
|
923
|
+
fail(
|
|
924
|
+
'Mail outbox storage does not support invalid-record deletion.',
|
|
925
|
+
'MAIL_OUTBOX_DELETE_UNAVAILABLE'
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async #deleteInvalidFile(fileName){
|
|
931
|
+
this.#assertDeleteAvailable();
|
|
932
|
+
try{
|
|
933
|
+
await this.#storage.delete(this.#table,fileName);
|
|
934
|
+
}catch(error){
|
|
935
|
+
fail(
|
|
936
|
+
'Mail outbox could not delete an invalid durable record.',
|
|
937
|
+
'MAIL_OUTBOX_STORAGE_FAILED',
|
|
938
|
+
Error,
|
|
939
|
+
error
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async #deleteConfirmedInvalid(target){
|
|
945
|
+
const current=await this.#inspectInvalidFile(target.fileName);
|
|
946
|
+
await this.#deleteInvalidFile(current.metadata.fileName);
|
|
947
|
+
await this.#inventory();
|
|
948
|
+
return Object.freeze({...current.metadata,deleted:true});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async #repairConfirmedInvalid(target,replacement){
|
|
952
|
+
const current=await this.#inspectInvalidFile(target.fileName);
|
|
953
|
+
if(!current.metadata.repairable){
|
|
954
|
+
fail(
|
|
955
|
+
'Mail outbox invalid filename cannot be repaired in place.',
|
|
956
|
+
'MAIL_OUTBOX_INVALID_RECORD_NOT_REPAIRABLE'
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
const key=reportKeyFromFileName(current.metadata.fileName);
|
|
960
|
+
const repaired=normalizedRecord(replacement,key);
|
|
961
|
+
const committed=await this.#commitNormalizedRecord(repaired);
|
|
962
|
+
await this.#inventory();
|
|
963
|
+
return committed;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async #quarantineConfirmedInvalid(target){
|
|
967
|
+
const current=await this.#inspectInvalidFile(target.fileName);
|
|
968
|
+
const snapshot=quarantineSnapshot(current.value,this.#maxReportBytes);
|
|
969
|
+
if(snapshot===null){
|
|
970
|
+
fail(
|
|
971
|
+
'Mail outbox cannot capture the invalid record for quarantine.',
|
|
972
|
+
'MAIL_OUTBOX_QUARANTINE_SNAPSHOT_UNAVAILABLE'
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
const entry=Object.freeze({
|
|
976
|
+
protocol:QUARANTINE_PROTOCOL,
|
|
977
|
+
sourceTable:this.#table,
|
|
978
|
+
sourceFileName:current.metadata.fileName,
|
|
979
|
+
quarantinedAt:this.#time(),
|
|
980
|
+
reasonCode:current.metadata.code,
|
|
981
|
+
serializedValue:snapshot
|
|
982
|
+
});
|
|
983
|
+
try{
|
|
984
|
+
await this.#storage.set(
|
|
985
|
+
this.#quarantineTable,
|
|
986
|
+
current.metadata.fileName,
|
|
987
|
+
entry
|
|
988
|
+
);
|
|
989
|
+
}catch(error){
|
|
990
|
+
fail(
|
|
991
|
+
'Mail outbox could not quarantine an invalid durable record.',
|
|
992
|
+
'MAIL_OUTBOX_STORAGE_FAILED',
|
|
993
|
+
Error,
|
|
994
|
+
error
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
await this.#deleteInvalidFile(current.metadata.fileName);
|
|
998
|
+
return Object.freeze({...current.metadata,quarantined:true});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async get(key){
|
|
1002
|
+
await this.#ready();
|
|
1003
|
+
return this.#read(reportKey(key));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async list(){
|
|
1007
|
+
await this.#ready();
|
|
1008
|
+
return (await this.#inventory()).records;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async audit(){
|
|
1012
|
+
await this.#ready();
|
|
1013
|
+
return this.#inventory();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async deleteInvalid(fileName){
|
|
1017
|
+
await this.#ready();
|
|
1018
|
+
const inventory=await this.#inventory();
|
|
1019
|
+
const target=this.#assertInvalidTarget(fileName,inventory);
|
|
1020
|
+
return this.#withTableMutation(
|
|
1021
|
+
this.#deleteConfirmedInvalid.bind(this,target)
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async repairInvalid(fileName,replacement){
|
|
1026
|
+
await this.#ready();
|
|
1027
|
+
const inventory=await this.#inventory();
|
|
1028
|
+
const target=this.#assertInvalidTarget(fileName,inventory);
|
|
1029
|
+
if(!target.repairable){
|
|
1030
|
+
fail(
|
|
1031
|
+
'Mail outbox invalid filename cannot be repaired in place.',
|
|
1032
|
+
'MAIL_OUTBOX_INVALID_RECORD_NOT_REPAIRABLE'
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
return this.#withTableMutation(
|
|
1036
|
+
this.#repairConfirmedInvalid.bind(this,target,replacement)
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async quarantineInvalid({limit=MAX_MAINTENANCE_BATCH}={}){
|
|
1041
|
+
await this.#ready();
|
|
1042
|
+
this.#assertDeleteAvailable();
|
|
1043
|
+
const batchLimit=positiveInteger(
|
|
1044
|
+
limit,
|
|
1045
|
+
'Mail outbox quarantine limit',
|
|
1046
|
+
MAX_MAINTENANCE_BATCH,
|
|
1047
|
+
MAX_MAINTENANCE_BATCH
|
|
1048
|
+
);
|
|
1049
|
+
const inventory=await this.#inventory();
|
|
1050
|
+
const targets=inventory.invalidRecords.slice(0,batchLimit);
|
|
1051
|
+
const quarantined=[];
|
|
1052
|
+
for(const target of targets){
|
|
1053
|
+
const result=await this.#withTableMutation(
|
|
1054
|
+
this.#quarantineConfirmedInvalid.bind(this,target)
|
|
1055
|
+
);
|
|
1056
|
+
quarantined.push(result);
|
|
1057
|
+
}
|
|
1058
|
+
const remaining=await this.#inventory();
|
|
1059
|
+
return deepFreeze({
|
|
1060
|
+
quarantined,
|
|
1061
|
+
remainingInvalidRecords:remaining.invalidRecords.length,
|
|
1062
|
+
truncated:remaining.truncated
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async #persistEnqueueLocked({serializedReport,key,signal}){
|
|
1067
|
+
throwIfAborted(signal);
|
|
1068
|
+
const existing=await this.#read(key);
|
|
1069
|
+
if(existing){
|
|
1070
|
+
if(existing.serializedReport!==serializedReport){
|
|
1071
|
+
fail(
|
|
1072
|
+
'Mail outbox reportKey is already bound to a different serialized report.',
|
|
1073
|
+
'MAIL_OUTBOX_IDEMPOTENCY_CONFLICT'
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
return existing;
|
|
1077
|
+
}
|
|
1078
|
+
const inventory=await this.#inventory();
|
|
1079
|
+
if(inventory.records.length>=this.#maxRecords){
|
|
1080
|
+
fail(
|
|
1081
|
+
`Mail outbox cannot exceed ${this.#maxRecords} records.`,
|
|
1082
|
+
'MAIL_OUTBOX_CAPACITY_EXCEEDED',
|
|
1083
|
+
RangeError
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const scanLimit=this.#maxRecords+this.#maxInvalidRecords;
|
|
1087
|
+
if(inventory.truncated||inventory.totalFiles>=scanLimit){
|
|
1088
|
+
fail(
|
|
1089
|
+
'Mail outbox inventory requires bounded invalid-record maintenance before enqueue.',
|
|
1090
|
+
'MAIL_OUTBOX_MAINTENANCE_REQUIRED'
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
const now=this.#time();
|
|
1094
|
+
const queued=normalizedRecord({
|
|
1095
|
+
protocol:MAIL_OUTBOX_PROTOCOL,
|
|
1096
|
+
reportKey:key,
|
|
1097
|
+
serializedReport,
|
|
1098
|
+
state:'queued',
|
|
1099
|
+
createdAt:now,
|
|
1100
|
+
updatedAt:now,
|
|
1101
|
+
firstAttemptAt:null,
|
|
1102
|
+
lastAttemptAt:null,
|
|
1103
|
+
nextAttemptAt:null,
|
|
1104
|
+
attempts:0,
|
|
1105
|
+
result:null,
|
|
1106
|
+
failure:null
|
|
1107
|
+
},key);
|
|
1108
|
+
return this.#commitNormalizedRecord(queued);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async #persistEnqueue(request){
|
|
1112
|
+
throwIfAborted(request.signal);
|
|
1113
|
+
await this.#ready();
|
|
1114
|
+
throwIfAborted(request.signal);
|
|
1115
|
+
return this.#withTableMutation(
|
|
1116
|
+
this.#persistEnqueueLocked.bind(this,request)
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async enqueue({report,reportKey:key}={}, {attempt=true,signal=null}={}){
|
|
1121
|
+
if(typeof attempt!=='boolean'){
|
|
1122
|
+
fail('Mail outbox attempt must be boolean.','MAIL_OUTBOX_INVALID',TypeError);
|
|
1123
|
+
}
|
|
1124
|
+
const resolvedSignal=validateSignal(signal);
|
|
1125
|
+
throwIfAborted(resolvedSignal);
|
|
1126
|
+
const normalizedKey=reportKey(key);
|
|
1127
|
+
const serializedReport=serializeReport(report,this.#maxReportBytes);
|
|
1128
|
+
const previous=this.#enqueuePromise;
|
|
1129
|
+
const operation=(previous??Promise.resolve()).catch(
|
|
1130
|
+
function ignorePriorEnqueueFailure(){}
|
|
1131
|
+
).then(
|
|
1132
|
+
this.#persistEnqueue.bind(this,{
|
|
1133
|
+
serializedReport,
|
|
1134
|
+
key:normalizedKey,
|
|
1135
|
+
signal:resolvedSignal
|
|
1136
|
+
})
|
|
1137
|
+
);
|
|
1138
|
+
this.#enqueuePromise=operation;
|
|
1139
|
+
let persisted;
|
|
1140
|
+
try{
|
|
1141
|
+
persisted=await operation;
|
|
1142
|
+
}finally{
|
|
1143
|
+
if(this.#enqueuePromise===operation) this.#enqueuePromise=null;
|
|
1144
|
+
}
|
|
1145
|
+
if(!attempt||terminalState(persisted.state)) return persisted;
|
|
1146
|
+
const activeDrain=this.#drainPromise;
|
|
1147
|
+
await this.drain({reason:'enqueue',signal:resolvedSignal});
|
|
1148
|
+
let current=await this.#read(normalizedKey);
|
|
1149
|
+
if(activeDrain&¤t?.state==='queued'){
|
|
1150
|
+
await this.drain({reason:'enqueue',signal:resolvedSignal});
|
|
1151
|
+
current=await this.#read(normalizedKey);
|
|
1152
|
+
}
|
|
1153
|
+
return current;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async #recoverSending(record,now){
|
|
1157
|
+
const expiresAt=record.firstAttemptAt+MAIL_OUTBOX_IDEMPOTENCY_WINDOW_MS;
|
|
1158
|
+
const failure=Object.freeze({
|
|
1159
|
+
code:'MAIL_OUTBOX_INTERRUPTED_ATTEMPT',
|
|
1160
|
+
statusCode:null,
|
|
1161
|
+
retryable:true,
|
|
1162
|
+
uncertain:true,
|
|
1163
|
+
retryAfterSeconds:null
|
|
1164
|
+
});
|
|
1165
|
+
if(now>=expiresAt){
|
|
1166
|
+
return this.#write(updatedRecord(record,{
|
|
1167
|
+
state:'reconciliation_required',
|
|
1168
|
+
updatedAt:Math.max(now,record.updatedAt),
|
|
1169
|
+
nextAttemptAt:null,
|
|
1170
|
+
failure
|
|
1171
|
+
}));
|
|
1172
|
+
}
|
|
1173
|
+
return this.#write(updatedRecord(record,{
|
|
1174
|
+
state:'retry_wait',
|
|
1175
|
+
updatedAt:Math.max(now,record.updatedAt),
|
|
1176
|
+
nextAttemptAt:now,
|
|
1177
|
+
failure
|
|
1178
|
+
}));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async #expireRetry(record,now){
|
|
1182
|
+
if(record.firstAttemptAt===null){
|
|
1183
|
+
fail('Retrying mail outbox record has no first attempt time.','MAIL_OUTBOX_RECORD_INVALID');
|
|
1184
|
+
}
|
|
1185
|
+
const expiresAt=record.firstAttemptAt+MAIL_OUTBOX_IDEMPOTENCY_WINDOW_MS;
|
|
1186
|
+
if(now<expiresAt) return record;
|
|
1187
|
+
const uncertain=record.failure?.uncertain===true;
|
|
1188
|
+
return this.#write(updatedRecord(record,{
|
|
1189
|
+
state:uncertain?'reconciliation_required':'failed',
|
|
1190
|
+
updatedAt:Math.max(now,record.updatedAt),
|
|
1191
|
+
nextAttemptAt:null,
|
|
1192
|
+
failure:Object.freeze({
|
|
1193
|
+
code:uncertain
|
|
1194
|
+
?'MAIL_OUTBOX_RECONCILIATION_REQUIRED'
|
|
1195
|
+
:'MAIL_OUTBOX_RETRY_WINDOW_EXPIRED',
|
|
1196
|
+
statusCode:record.failure?.statusCode??null,
|
|
1197
|
+
retryable:false,
|
|
1198
|
+
uncertain,
|
|
1199
|
+
retryAfterSeconds:null
|
|
1200
|
+
})
|
|
1201
|
+
}));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async #recordRetry(record,outcome,now){
|
|
1205
|
+
const expiresAt=record.firstAttemptAt+MAIL_OUTBOX_IDEMPOTENCY_WINDOW_MS;
|
|
1206
|
+
const requestedAt=outcome.failure.retryAfterSeconds===null
|
|
1207
|
+
?now
|
|
1208
|
+
:now+outcome.failure.retryAfterSeconds*1000;
|
|
1209
|
+
if(now>=expiresAt||requestedAt>=expiresAt){
|
|
1210
|
+
const uncertain=outcome.failure.uncertain;
|
|
1211
|
+
return this.#write(updatedRecord(record,{
|
|
1212
|
+
state:uncertain?'reconciliation_required':'failed',
|
|
1213
|
+
updatedAt:Math.max(now,record.updatedAt),
|
|
1214
|
+
nextAttemptAt:null,
|
|
1215
|
+
result:outcome.result,
|
|
1216
|
+
failure:Object.freeze({
|
|
1217
|
+
...outcome.failure,
|
|
1218
|
+
code:uncertain
|
|
1219
|
+
?'MAIL_OUTBOX_RECONCILIATION_REQUIRED'
|
|
1220
|
+
:'MAIL_OUTBOX_RETRY_WINDOW_EXPIRED',
|
|
1221
|
+
retryable:false,
|
|
1222
|
+
retryAfterSeconds:null
|
|
1223
|
+
})
|
|
1224
|
+
}));
|
|
1225
|
+
}
|
|
1226
|
+
return this.#write(updatedRecord(record,{
|
|
1227
|
+
state:'retry_wait',
|
|
1228
|
+
updatedAt:Math.max(now,record.updatedAt),
|
|
1229
|
+
nextAttemptAt:requestedAt,
|
|
1230
|
+
result:outcome.result,
|
|
1231
|
+
failure:outcome.failure
|
|
1232
|
+
}));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async #attempt(record,signal){
|
|
1236
|
+
throwIfAborted(signal);
|
|
1237
|
+
const startedAt=Math.max(this.#time(),record.updatedAt);
|
|
1238
|
+
const firstAttemptAt=record.firstAttemptAt??startedAt;
|
|
1239
|
+
if(startedAt>=firstAttemptAt+MAIL_OUTBOX_IDEMPOTENCY_WINDOW_MS){
|
|
1240
|
+
return this.#expireRetry(record,startedAt);
|
|
1241
|
+
}
|
|
1242
|
+
const sending=await this.#write(updatedRecord(record,{
|
|
1243
|
+
state:'sending',
|
|
1244
|
+
updatedAt:startedAt,
|
|
1245
|
+
firstAttemptAt,
|
|
1246
|
+
lastAttemptAt:startedAt,
|
|
1247
|
+
nextAttemptAt:null,
|
|
1248
|
+
attempts:record.attempts+1,
|
|
1249
|
+
result:null,
|
|
1250
|
+
failure:null
|
|
1251
|
+
}));
|
|
1252
|
+
if(signal?.aborted){
|
|
1253
|
+
await this.#write(updatedRecord(record,{
|
|
1254
|
+
updatedAt:Math.max(this.#time(),sending.updatedAt)
|
|
1255
|
+
}));
|
|
1256
|
+
throwIfAborted(signal);
|
|
1257
|
+
}
|
|
1258
|
+
let outcome;
|
|
1259
|
+
let deliveryError=null;
|
|
1260
|
+
try{
|
|
1261
|
+
const value=await this.#deliver(deliveryRequest(sending,signal));
|
|
1262
|
+
outcome=deliveryResult(value);
|
|
1263
|
+
}catch(error){
|
|
1264
|
+
deliveryError=error;
|
|
1265
|
+
outcome=deliveryFailure(error);
|
|
1266
|
+
}
|
|
1267
|
+
const completedAt=Math.max(this.#time(),sending.updatedAt);
|
|
1268
|
+
let committed;
|
|
1269
|
+
if(outcome.kind==='accepted'){
|
|
1270
|
+
committed=await this.#write(updatedRecord(sending,{
|
|
1271
|
+
state:'accepted',
|
|
1272
|
+
updatedAt:completedAt,
|
|
1273
|
+
nextAttemptAt:null,
|
|
1274
|
+
result:outcome.result,
|
|
1275
|
+
failure:null
|
|
1276
|
+
}));
|
|
1277
|
+
}else if(outcome.kind==='retry'){
|
|
1278
|
+
committed=await this.#recordRetry(sending,outcome,completedAt);
|
|
1279
|
+
}else{
|
|
1280
|
+
committed=await this.#write(updatedRecord(sending,{
|
|
1281
|
+
state:'failed',
|
|
1282
|
+
updatedAt:completedAt,
|
|
1283
|
+
nextAttemptAt:null,
|
|
1284
|
+
result:outcome.result,
|
|
1285
|
+
failure:outcome.failure
|
|
1286
|
+
}));
|
|
1287
|
+
}
|
|
1288
|
+
const cancellation=deliveryError
|
|
1289
|
+
?abortedDeliveryError(deliveryError,signal)
|
|
1290
|
+
:null;
|
|
1291
|
+
if(cancellation) throw cancellation;
|
|
1292
|
+
return committed;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
async #performDrain({reason,signal}){
|
|
1296
|
+
await this.#ready();
|
|
1297
|
+
throwIfAborted(signal);
|
|
1298
|
+
const inventory=await this.#inventory();
|
|
1299
|
+
const records=[...inventory.records];
|
|
1300
|
+
const invalidRecords=inventory.invalidRecords.length;
|
|
1301
|
+
let attempted=0;
|
|
1302
|
+
let bounded=inventory.truncated||inventory.overCapacity;
|
|
1303
|
+
const online=this.#online();
|
|
1304
|
+
for(let index=0;index<records.length;index+=1){
|
|
1305
|
+
throwIfAborted(signal);
|
|
1306
|
+
let record=records[index];
|
|
1307
|
+
const now=Math.max(this.#time(),record.updatedAt);
|
|
1308
|
+
if(record.state==='sending'){
|
|
1309
|
+
record=await this.#recoverSending(record,now);
|
|
1310
|
+
records[index]=record;
|
|
1311
|
+
}
|
|
1312
|
+
if(record.state==='retry_wait'){
|
|
1313
|
+
record=await this.#expireRetry(record,now);
|
|
1314
|
+
records[index]=record;
|
|
1315
|
+
}
|
|
1316
|
+
if(terminalState(record.state)) continue;
|
|
1317
|
+
if(record.state==='retry_wait'&&record.nextAttemptAt!==null&&record.nextAttemptAt>now){
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
if(!online||!this.#online()) continue;
|
|
1321
|
+
if(attempted>=this.#maxAttemptsPerDrain){
|
|
1322
|
+
bounded=true;
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
record=await this.#attempt(record,signal);
|
|
1326
|
+
records[index]=record;
|
|
1327
|
+
attempted+=1;
|
|
1328
|
+
}
|
|
1329
|
+
return summary(reason,online,records,attempted,invalidRecords,bounded);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
async drain({reason='manual',signal=null}={}){
|
|
1333
|
+
const normalizedReason=drainReason(reason);
|
|
1334
|
+
const normalizedSignal=validateSignal(signal);
|
|
1335
|
+
throwIfAborted(normalizedSignal);
|
|
1336
|
+
if(this.#drainPromise){
|
|
1337
|
+
return waitForOutboxOperation(this.#drainPromise,normalizedSignal);
|
|
1338
|
+
}
|
|
1339
|
+
const operation=this.#withDrainAuthority(
|
|
1340
|
+
this.#performDrain.bind(this,{
|
|
1341
|
+
reason:normalizedReason,
|
|
1342
|
+
signal:normalizedSignal
|
|
1343
|
+
}),
|
|
1344
|
+
normalizedSignal
|
|
1345
|
+
);
|
|
1346
|
+
this.#drainPromise=operation;
|
|
1347
|
+
try{
|
|
1348
|
+
return await operation;
|
|
1349
|
+
}finally{
|
|
1350
|
+
if(this.#drainPromise===operation) this.#drainPromise=null;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async #handleOnline(){
|
|
1355
|
+
if(this.#onlineController) return;
|
|
1356
|
+
const controller=new AbortController();
|
|
1357
|
+
this.#onlineController=controller;
|
|
1358
|
+
try{
|
|
1359
|
+
await this.drain({reason:'online',signal:controller.signal});
|
|
1360
|
+
this.#lastBackgroundError=null;
|
|
1361
|
+
}catch(error){
|
|
1362
|
+
if(error?.name!=='AbortError'&&error?.code!=='MAIL_OUTBOX_ABORTED'){
|
|
1363
|
+
this.#lastBackgroundError=error;
|
|
1364
|
+
}
|
|
1365
|
+
}finally{
|
|
1366
|
+
if(this.#onlineController===controller) this.#onlineController=null;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async start({signal=null}={}){
|
|
1371
|
+
const normalizedSignal=validateSignal(signal);
|
|
1372
|
+
throwIfAborted(normalizedSignal);
|
|
1373
|
+
if(!this.#started){
|
|
1374
|
+
this.#onlineTarget?.addEventListener('online',this.#onlineHandler);
|
|
1375
|
+
this.#started=true;
|
|
1376
|
+
}
|
|
1377
|
+
return this.drain({reason:'startup',signal:normalizedSignal});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
stop(){
|
|
1381
|
+
this.#onlineController?.abort(new Error('Mail outbox online drain was stopped.'));
|
|
1382
|
+
if(this.#started){
|
|
1383
|
+
this.#onlineTarget?.removeEventListener('online',this.#onlineHandler);
|
|
1384
|
+
this.#started=false;
|
|
1385
|
+
}
|
|
1386
|
+
return this;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function createMailOutbox(options){
|
|
1391
|
+
return new MailOutbox(options);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
export {MailOutbox,createMailOutbox};
|
|
1395
|
+
export default MailOutbox;
|