@push.rocks/smartmta 8.1.0 → 8.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "8.1.0",
3
+ "version": "8.1.1",
4
4
  "description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
5
5
  "keywords": [
6
6
  "mta",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '8.1.0',
6
+ version: '8.1.1',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  }
package/ts/index.ts CHANGED
@@ -13,4 +13,5 @@ export const smartMtaCapabilities = Object.freeze({
13
13
  outboundAttachments: true,
14
14
  topologyAwareSelfMx: true,
15
15
  durableDeliveryFailureOutbox: true,
16
+ exactAcceptedEnvelopeDispatch: true,
16
17
  } as const);
@@ -58,6 +58,10 @@ export interface IBounceOperation {
58
58
  */
59
59
  export interface IQueueItem {
60
60
  id: string;
61
+ /** Stable caller-supplied key used to make enqueue replay-safe. */
62
+ idempotencyKey?: string;
63
+ /** Fingerprint of the exact work associated with the idempotency key. */
64
+ idempotencyFingerprint?: string;
61
65
  processingMode: EmailProcessingMode;
62
66
  processingResult: Email;
63
67
  /** Materialized attachment count for queue/detail consumers. */
@@ -86,6 +90,8 @@ interface IStoredEmail {
86
90
  interface IStoredQueueItemV1 {
87
91
  schemaVersion: 1;
88
92
  id: string;
93
+ idempotencyKey?: string;
94
+ idempotencyFingerprint?: string;
89
95
  processingMode: EmailProcessingMode;
90
96
  processingResult: IStoredEmail;
91
97
  route?: IEmailRoute;
@@ -121,6 +127,17 @@ type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
121
127
 
122
128
  type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
123
129
 
130
+ interface IStoredQueueIdempotencyTombstone {
131
+ schemaVersion: 1;
132
+ id: string;
133
+ idempotencyKey: string;
134
+ idempotencyFingerprint: string;
135
+ expiresAt: number;
136
+ }
137
+
138
+ export const ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS = 31 * 24 * 60 * 60 * 1000;
139
+ const QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT = 256;
140
+
124
141
  export interface IQueueFailureOptions {
125
142
  /** Unknown failures default to retryable so temporary internal faults do not lose mail. */
126
143
  retryable?: boolean;
@@ -159,6 +176,15 @@ export interface IQueueOptions {
159
176
  maxRetryDelay?: number;
160
177
  }
161
178
 
179
+ export interface IQueueEnqueueOptions {
180
+ /** Stable key that resolves to one deterministic queue item id. */
181
+ idempotencyKey?: string;
182
+ /** Required with idempotencyKey to reject reuse for different work. */
183
+ idempotencyFingerprint?: string;
184
+ /** Fail closed unless the queue is backed by managed blob storage. */
185
+ requireDurable?: boolean;
186
+ }
187
+
162
188
  /**
163
189
  * Queue statistics interface
164
190
  */
@@ -225,6 +251,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
225
251
  private totalProcessed: number = 0;
226
252
  private ownsManagedPrefix = false;
227
253
  private lifecycleState: TQueueLifecycleState = 'new';
254
+ private readonly idempotentEnqueueFlights = new Map<string, {
255
+ fingerprint: string;
256
+ promise: Promise<string>;
257
+ }>();
258
+ private idempotencyTombstoneCleanupCursor?: string;
228
259
 
229
260
  /**
230
261
  * Create a new unified delivery queue
@@ -279,6 +310,143 @@ export class UnifiedDeliveryQueue extends EventEmitter {
279
310
  }
280
311
  }
281
312
 
313
+ /** Whether queue entries are committed through caller-managed blob storage. */
314
+ public hasManagedStorage(): boolean {
315
+ return !!this.options.storageManager;
316
+ }
317
+
318
+ /** Whether managed storage can list a backend-bounded page of keys. */
319
+ public hasPaginatedManagedStorage(): boolean {
320
+ return typeof this.options.storageManager?.listPage === 'function';
321
+ }
322
+
323
+ private idempotentQueueId(idempotencyKey: string): string {
324
+ return `idempotent-${createHash('sha256')
325
+ .update('smartmta-queue-v1\0')
326
+ .update(idempotencyKey)
327
+ .digest('hex')}`;
328
+ }
329
+
330
+ private idempotencyTombstonePrefix(): string {
331
+ return `${this.options.storagePrefix.slice(0, -1)}.idempotency/`;
332
+ }
333
+
334
+ private idempotencyTombstoneKey(id: string): string {
335
+ return `${this.idempotencyTombstonePrefix()}${id}.json`;
336
+ }
337
+
338
+ private async readIdempotencyTombstone(
339
+ id: string,
340
+ ): Promise<IStoredQueueIdempotencyTombstone | null> {
341
+ const storageManager = this.options.storageManager;
342
+ if (!storageManager) return null;
343
+ const key = this.idempotencyTombstoneKey(id);
344
+ const value = await storageManager.get(key);
345
+ if (!value) return null;
346
+ const tombstone = this.parseIdempotencyTombstone(value, key, id);
347
+ if (tombstone.expiresAt <= Date.now()) {
348
+ await storageManager.delete(key);
349
+ return null;
350
+ }
351
+ return tombstone;
352
+ }
353
+
354
+ private parseIdempotencyTombstone(
355
+ value: Buffer,
356
+ key: string,
357
+ expectedId: string,
358
+ ): IStoredQueueIdempotencyTombstone {
359
+ let tombstone: IStoredQueueIdempotencyTombstone;
360
+ try {
361
+ tombstone = JSON.parse(value.toString('utf8')) as IStoredQueueIdempotencyTombstone;
362
+ } catch {
363
+ throw new Error(`Invalid queue idempotency tombstone at ${key}`);
364
+ }
365
+ if (
366
+ tombstone?.schemaVersion !== 1
367
+ || tombstone.id !== expectedId
368
+ || typeof tombstone.idempotencyKey !== 'string'
369
+ || !tombstone.idempotencyKey
370
+ || tombstone.idempotencyKey.length > 512
371
+ || this.idempotentQueueId(tombstone.idempotencyKey) !== expectedId
372
+ || typeof tombstone.idempotencyFingerprint !== 'string'
373
+ || !/^[a-f0-9]{64}$/.test(tombstone.idempotencyFingerprint)
374
+ || !Number.isFinite(tombstone.expiresAt)
375
+ ) {
376
+ throw new Error(`Invalid queue idempotency tombstone at ${key}`);
377
+ }
378
+ return tombstone;
379
+ }
380
+
381
+ private async persistIdempotencyTombstone(
382
+ id: string,
383
+ idempotencyKey: string,
384
+ idempotencyFingerprint: string,
385
+ ): Promise<void> {
386
+ const storageManager = this.options.storageManager;
387
+ if (!storageManager) return;
388
+ const tombstone: IStoredQueueIdempotencyTombstone = {
389
+ schemaVersion: 1,
390
+ id,
391
+ idempotencyKey,
392
+ idempotencyFingerprint,
393
+ expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
394
+ };
395
+ await storageManager.set(
396
+ this.idempotencyTombstoneKey(id),
397
+ Buffer.from(JSON.stringify(tombstone)),
398
+ );
399
+ }
400
+
401
+ private async cleanupExpiredIdempotencyTombstones(
402
+ limit = QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT,
403
+ ): Promise<void> {
404
+ const storageManager = this.options.storageManager;
405
+ if (!storageManager?.listPage || limit < 1) return;
406
+ const prefix = this.idempotencyTombstonePrefix();
407
+ const page = await storageManager.listPage(prefix, {
408
+ limit,
409
+ ...(this.idempotencyTombstoneCleanupCursor
410
+ ? { continuationToken: this.idempotencyTombstoneCleanupCursor }
411
+ : {}),
412
+ });
413
+ if (
414
+ !page
415
+ || !Array.isArray(page.keys)
416
+ || page.keys.length > limit
417
+ || page.keys.some((key) => typeof key !== 'string' || !key.startsWith(prefix))
418
+ || new Set(page.keys).size !== page.keys.length
419
+ || (
420
+ page.continuationToken !== undefined
421
+ && (
422
+ typeof page.continuationToken !== 'string'
423
+ || page.continuationToken.length === 0
424
+ || page.continuationToken === this.idempotencyTombstoneCleanupCursor
425
+ )
426
+ )
427
+ ) {
428
+ throw new Error('Invalid paginated queue idempotency tombstone listing');
429
+ }
430
+ this.idempotencyTombstoneCleanupCursor = page.continuationToken;
431
+ const now = Date.now();
432
+ for (const key of page.keys) {
433
+ const value = await storageManager.get(key);
434
+ if (!value) continue;
435
+ try {
436
+ if (!key.endsWith('.json')) {
437
+ throw new Error(`Invalid queue idempotency tombstone key: ${key}`);
438
+ }
439
+ const id = key.slice(prefix.length, -'.json'.length);
440
+ const tombstone = this.parseIdempotencyTombstone(value, key, id);
441
+ if (tombstone.expiresAt <= now) {
442
+ await storageManager.delete(key);
443
+ }
444
+ } catch {
445
+ await storageManager.delete(key);
446
+ }
447
+ }
448
+ }
449
+
282
450
  private createInitialRecipientStates(email: Email): IRecipientDeliveryState[] {
283
451
  const byRecipient = new Map<string, IRecipientDeliveryState>();
284
452
  const now = new Date();
@@ -461,23 +629,113 @@ export class UnifiedDeliveryQueue extends EventEmitter {
461
629
  processingResult: Email,
462
630
  mode: EmailProcessingMode,
463
631
  route?: IEmailRoute,
632
+ enqueueOptions: IQueueEnqueueOptions = {},
464
633
  ): Promise<string> {
465
634
  this.assertMutable();
466
635
  if (!(processingResult instanceof Email)) {
467
636
  throw new Error('Queue processingResult must be an Email instance');
468
637
  }
469
638
 
639
+ if (enqueueOptions.requireDurable && !this.options.storageManager) {
640
+ throw new Error('Durable idempotent enqueue requires managed queue storage');
641
+ }
642
+ if (
643
+ (enqueueOptions.idempotencyKey === undefined)
644
+ !== (enqueueOptions.idempotencyFingerprint === undefined)
645
+ ) {
646
+ throw new Error('Queue idempotencyKey and idempotencyFingerprint must be provided together');
647
+ }
648
+ if (enqueueOptions.idempotencyKey !== undefined) {
649
+ if (this.options.storageManager && !this.options.storageManager.listPage) {
650
+ throw new Error(
651
+ 'Managed idempotent enqueue requires paginated managed queue storage',
652
+ );
653
+ }
654
+ if (!enqueueOptions.idempotencyKey || enqueueOptions.idempotencyKey.length > 512) {
655
+ throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
656
+ }
657
+ if (!/^[a-f0-9]{64}$/.test(enqueueOptions.idempotencyFingerprint!)) {
658
+ throw new Error('Queue idempotencyFingerprint must be a lowercase SHA-256 digest');
659
+ }
660
+
661
+ const id = this.idempotentQueueId(enqueueOptions.idempotencyKey);
662
+ const existing = this.queue.get(id);
663
+ if (existing) {
664
+ if (
665
+ existing.idempotencyKey !== enqueueOptions.idempotencyKey
666
+ || existing.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
667
+ ) {
668
+ throw new Error(`Queue idempotency key collision for ${id}`);
669
+ }
670
+ return id;
671
+ }
672
+
673
+ const activeFlight = this.idempotentEnqueueFlights.get(id);
674
+ if (activeFlight) {
675
+ if (activeFlight.fingerprint !== enqueueOptions.idempotencyFingerprint) {
676
+ throw new Error(`Queue idempotency key collision for ${id}`);
677
+ }
678
+ return activeFlight.promise;
679
+ }
680
+ const flight = (async () => {
681
+ const tombstone = await this.readIdempotencyTombstone(id);
682
+ if (tombstone) {
683
+ if (
684
+ tombstone.idempotencyKey !== enqueueOptions.idempotencyKey
685
+ || tombstone.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
686
+ ) {
687
+ throw new Error(`Queue idempotency key collision for ${id}`);
688
+ }
689
+ return id;
690
+ }
691
+ return this.enqueueNew(processingResult, mode, route, {
692
+ id,
693
+ idempotencyKey: enqueueOptions.idempotencyKey!,
694
+ idempotencyFingerprint: enqueueOptions.idempotencyFingerprint!,
695
+ });
696
+ })();
697
+ this.idempotentEnqueueFlights.set(id, {
698
+ fingerprint: enqueueOptions.idempotencyFingerprint!,
699
+ promise: flight,
700
+ });
701
+ try {
702
+ return await flight;
703
+ } finally {
704
+ if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
705
+ this.idempotentEnqueueFlights.delete(id);
706
+ }
707
+ }
708
+ }
709
+
710
+ return this.enqueueNew(processingResult, mode, route);
711
+ }
712
+
713
+ private async enqueueNew(
714
+ processingResult: Email,
715
+ mode: EmailProcessingMode,
716
+ route?: IEmailRoute,
717
+ idempotency?: {
718
+ id: string;
719
+ idempotencyKey: string;
720
+ idempotencyFingerprint: string;
721
+ },
722
+ ): Promise<string> {
470
723
  // Check if queue is full
471
724
  if (this.queue.size >= this.options.maxQueueSize) {
472
725
  throw new Error('Queue is full');
473
726
  }
474
727
 
475
728
  // Generate a unique ID
476
- const id = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
729
+ const id = idempotency?.id
730
+ || `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
477
731
 
478
732
  // Create queue item
479
733
  const item: IQueueItem = {
480
734
  id,
735
+ ...(idempotency ? {
736
+ idempotencyKey: idempotency.idempotencyKey,
737
+ idempotencyFingerprint: idempotency.idempotencyFingerprint,
738
+ } : {}),
481
739
  processingMode: mode,
482
740
  processingResult,
483
741
  attachmentCount: processingResult.attachments.length,
@@ -496,6 +754,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
496
754
 
497
755
  // Commit durable state before exposing the item in memory or emitting acceptance.
498
756
  await this.persistItem(item);
757
+ if (idempotency) {
758
+ await this.persistIdempotencyTombstone(
759
+ id,
760
+ idempotency.idempotencyKey,
761
+ idempotency.idempotencyFingerprint,
762
+ );
763
+ }
499
764
  this.queue.set(id, item);
500
765
 
501
766
  // Update statistics
@@ -978,6 +1243,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
978
1243
  return false;
979
1244
  }
980
1245
 
1246
+ if (item.idempotencyKey && item.idempotencyFingerprint) {
1247
+ await this.persistIdempotencyTombstone(
1248
+ item.id,
1249
+ item.idempotencyKey,
1250
+ item.idempotencyFingerprint,
1251
+ );
1252
+ }
981
1253
  // Delete durable state before making the removal visible in memory.
982
1254
  await this.removeItemFromStorage(id);
983
1255
  this.queue.delete(id);
@@ -1008,6 +1280,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1008
1280
  return {
1009
1281
  schemaVersion: 2,
1010
1282
  id: item.id,
1283
+ ...(item.idempotencyKey !== undefined ? {
1284
+ idempotencyKey: item.idempotencyKey,
1285
+ idempotencyFingerprint: item.idempotencyFingerprint,
1286
+ } : {}),
1011
1287
  processingMode: item.processingMode,
1012
1288
  processingResult: {
1013
1289
  options: item.processingResult.toEmailOptions(),
@@ -1081,6 +1357,27 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1081
1357
  if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
1082
1358
  throw new Error(`Invalid managed queue item at ${key}: missing id`);
1083
1359
  }
1360
+ if (
1361
+ parsed.idempotencyKey !== undefined
1362
+ && (
1363
+ typeof parsed.idempotencyKey !== 'string'
1364
+ || !parsed.idempotencyKey
1365
+ || parsed.idempotencyKey.length > 512
1366
+ || typeof parsed.idempotencyFingerprint !== 'string'
1367
+ || !/^[a-f0-9]{64}$/.test(parsed.idempotencyFingerprint)
1368
+ )
1369
+ ) {
1370
+ throw new Error(`Invalid managed queue item at ${key}: invalid idempotency metadata`);
1371
+ }
1372
+ if (parsed.idempotencyKey === undefined && parsed.idempotencyFingerprint !== undefined) {
1373
+ throw new Error(`Invalid managed queue item at ${key}: orphaned idempotency fingerprint`);
1374
+ }
1375
+ if (parsed.idempotencyKey !== undefined) {
1376
+ const expectedId = this.idempotentQueueId(parsed.idempotencyKey);
1377
+ if (parsed.id !== expectedId) {
1378
+ throw new Error(`Invalid managed queue item at ${key}: idempotency id mismatch`);
1379
+ }
1380
+ }
1084
1381
  if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
1085
1382
  throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
1086
1383
  }
@@ -1362,6 +1659,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1362
1659
 
1363
1660
  return {
1364
1661
  id: parsed.id,
1662
+ ...(parsed.idempotencyKey !== undefined ? {
1663
+ idempotencyKey: parsed.idempotencyKey,
1664
+ idempotencyFingerprint: parsed.idempotencyFingerprint,
1665
+ } : {}),
1365
1666
  processingMode: parsed.processingMode,
1366
1667
  processingResult,
1367
1668
  attachmentCount: parsed.attachmentCount ?? processingResult.attachments.length,
@@ -1551,6 +1852,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1551
1852
  for (const id of idsToRemove) {
1552
1853
  await this.removeItem(id);
1553
1854
  }
1855
+ await this.cleanupExpiredIdempotencyTombstones();
1554
1856
 
1555
1857
  if (idsToRemove.length > 0) {
1556
1858
  logger.log('info', `Cleaned up ${idsToRemove.length} old items from delivery queue`);
@@ -9,6 +9,23 @@ export interface IStorageManager {
9
9
  get(key: string): Promise<string | null>;
10
10
  set(key: string, value: string): Promise<void>;
11
11
  list(prefix: string): Promise<string[]>;
12
+ /**
13
+ * Return at most `limit` keys from one backend-bounded page.
14
+ *
15
+ * Continuation tokens are opaque, scoped to the supplied prefix, and must
16
+ * only be passed back to the same storage implementation. A returned token
17
+ * must advance a non-overlapping traversal, remain valid after any keys in
18
+ * its page are deleted, and eventually exhaust an otherwise stable prefix.
19
+ * Offset-based tokens do not satisfy this deletion-stability requirement.
20
+ *
21
+ * This optional method preserves compatibility for existing callers;
22
+ * features which need bounded cleanup must require it explicitly and must
23
+ * never fall back to list().
24
+ */
25
+ listPage?(
26
+ prefix: string,
27
+ options: IStorageListPageOptions,
28
+ ): Promise<IStorageListPageResult>;
12
29
  delete(key: string): Promise<void>;
13
30
  }
14
31
 
@@ -17,18 +34,131 @@ export interface IBlobStorageManager {
17
34
  get(key: string): Promise<Buffer | null>;
18
35
  set(key: string, value: Buffer): Promise<void>;
19
36
  list(prefix: string): Promise<string[]>;
37
+ /** Backend-bounded counterpart to list(); see IStorageManager.listPage. */
38
+ listPage?(
39
+ prefix: string,
40
+ options: IStorageListPageOptions,
41
+ ): Promise<IStorageListPageResult>;
20
42
  delete(key: string): Promise<void>;
21
43
  }
22
44
 
45
+ export interface IStorageListPageOptions {
46
+ /** Positive maximum number of keys the backend may return. */
47
+ readonly limit: number;
48
+ /** Opaque token returned by the immediately preceding page. */
49
+ readonly continuationToken?: string;
50
+ }
51
+
52
+ export interface IStorageListPageResult {
53
+ /** Unique keys under the requested prefix; length must not exceed limit. */
54
+ readonly keys: string[];
55
+ /** Omitted when the prefix has been exhausted. */
56
+ readonly continuationToken?: string;
57
+ }
58
+
59
+ /** Hard upper bound for one SmartMTA storage listing page. */
60
+ export const SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT = 1_000;
61
+
62
+ function assertValidListPageOptions(options: IStorageListPageOptions): void {
63
+ if (
64
+ !Number.isInteger(options.limit)
65
+ || options.limit < 1
66
+ || options.limit > SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT
67
+ ) {
68
+ throw new Error(
69
+ `Storage list page limit must be an integer from 1 to ${SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT}`,
70
+ );
71
+ }
72
+ if (
73
+ options.continuationToken !== undefined
74
+ && (
75
+ typeof options.continuationToken !== 'string'
76
+ || options.continuationToken.length === 0
77
+ )
78
+ ) {
79
+ throw new Error('Storage list page continuation token must be a non-empty string');
80
+ }
81
+ }
82
+
83
+ function lowerBound(sortedKeys: readonly string[], target: string): number {
84
+ let low = 0;
85
+ let high = sortedKeys.length;
86
+ while (low < high) {
87
+ const middle = low + Math.floor((high - low) / 2);
88
+ if (sortedKeys[middle] < target) low = middle + 1;
89
+ else high = middle;
90
+ }
91
+ return low;
92
+ }
93
+
94
+ function upperBound(sortedKeys: readonly string[], target: string): number {
95
+ let low = 0;
96
+ let high = sortedKeys.length;
97
+ while (low < high) {
98
+ const middle = low + Math.floor((high - low) / 2);
99
+ if (sortedKeys[middle] <= target) low = middle + 1;
100
+ else high = middle;
101
+ }
102
+ return low;
103
+ }
104
+
105
+ function insertSortedKey(sortedKeys: string[], key: string): void {
106
+ sortedKeys.splice(lowerBound(sortedKeys, key), 0, key);
107
+ }
108
+
109
+ function removeSortedKey(sortedKeys: string[], key: string): void {
110
+ const index = lowerBound(sortedKeys, key);
111
+ if (sortedKeys[index] === key) sortedKeys.splice(index, 1);
112
+ }
113
+
114
+ function listSortedPrefixPage(
115
+ sortedKeys: readonly string[],
116
+ prefix: string,
117
+ options: IStorageListPageOptions,
118
+ ): IStorageListPageResult {
119
+ assertValidListPageOptions(options);
120
+ if (
121
+ options.continuationToken !== undefined
122
+ && !options.continuationToken.startsWith(prefix)
123
+ ) {
124
+ throw new Error('Storage list page continuation token does not belong to the prefix');
125
+ }
126
+ let index = options.continuationToken === undefined
127
+ ? lowerBound(sortedKeys, prefix)
128
+ : Math.max(
129
+ lowerBound(sortedKeys, prefix),
130
+ upperBound(sortedKeys, options.continuationToken),
131
+ );
132
+ const keys: string[] = [];
133
+ while (index < sortedKeys.length && keys.length < options.limit) {
134
+ const key = sortedKeys[index];
135
+ if (!key.startsWith(prefix)) break;
136
+ keys.push(key);
137
+ index++;
138
+ }
139
+ const hasMore = (
140
+ index < sortedKeys.length
141
+ && sortedKeys[index].startsWith(prefix)
142
+ );
143
+ return {
144
+ keys,
145
+ ...(hasMore && keys.length > 0
146
+ ? { continuationToken: keys[keys.length - 1] }
147
+ : {}),
148
+ };
149
+ }
150
+
23
151
  /** In-memory small-state store for tests and explicitly non-durable runtimes. */
24
152
  export class MemoryStorageManager implements IStorageManager {
25
153
  private readonly entries = new Map<string, string>();
154
+ private readonly sortedKeys: string[] = [];
26
155
 
27
156
  public async get(key: string): Promise<string | null> {
28
157
  return this.entries.get(key) ?? null;
29
158
  }
30
159
 
31
160
  public async set(key: string, value: string): Promise<void> {
161
+ if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
32
162
  this.entries.set(key, value);
33
163
  }
34
164
 
@@ -36,14 +166,22 @@ export class MemoryStorageManager implements IStorageManager {
36
166
  return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
37
167
  }
38
168
 
169
+ public async listPage(
170
+ prefix: string,
171
+ options: IStorageListPageOptions,
172
+ ): Promise<IStorageListPageResult> {
173
+ return listSortedPrefixPage(this.sortedKeys, prefix, options);
174
+ }
175
+
39
176
  public async delete(key: string): Promise<void> {
40
- this.entries.delete(key);
177
+ if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
41
178
  }
42
179
  }
43
180
 
44
181
  /** In-memory blob store for tests and explicitly non-durable runtimes. */
45
182
  export class MemoryBlobStorageManager implements IBlobStorageManager {
46
183
  private readonly entries = new Map<string, Buffer>();
184
+ private readonly sortedKeys: string[] = [];
47
185
 
48
186
  public async get(key: string): Promise<Buffer | null> {
49
187
  const value = this.entries.get(key);
@@ -51,6 +189,7 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
51
189
  }
52
190
 
53
191
  public async set(key: string, value: Buffer): Promise<void> {
192
+ if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
54
193
  this.entries.set(key, Buffer.from(value));
55
194
  }
56
195
 
@@ -58,8 +197,15 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
58
197
  return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
59
198
  }
60
199
 
200
+ public async listPage(
201
+ prefix: string,
202
+ options: IStorageListPageOptions,
203
+ ): Promise<IStorageListPageResult> {
204
+ return listSortedPrefixPage(this.sortedKeys, prefix, options);
205
+ }
206
+
61
207
  public async delete(key: string): Promise<void> {
62
- this.entries.delete(key);
208
+ if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
63
209
  }
64
210
  }
65
211