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.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. 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&&current?.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;