Skip to content

Commit 6a241e5

Browse files
committed
feat(outbox): add retry, close, maxAttempts, and observability callbacks
Address PR review feedback for the @append/outbox package: - Add clarifying comment for attemptCount backoff calculation - Add close() method to OutboxInstance and OutboxBroadcast for cleanup - Add retry(itemId) method to re-attempt failed items - Add optional maxAttempts parameter (default: unlimited) - Add observability callbacks: onItemProcessed, onRetryScheduled - Add clarifying comment in outbox-adapter for error handling behavior - Add adapter unit tests for error classification and transport outcomes - Update README with error recovery docs and publishing notes
1 parent d12eaf1 commit 6a241e5

11 files changed

Lines changed: 974 additions & 7 deletions

File tree

packages/outbox/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,18 @@ Options:
9898
- `createResultPayload`: Factory to create broadcast result payloads
9999
- `onAuthBlocked?`: Callback when auth is blocked
100100
- `undoGraceMs?`: Undo window duration (default: 5000ms)
101+
- `maxAttempts?`: Maximum retry attempts before failing (default: unlimited)
102+
- `onItemProcessed?`: Callback after each item is processed (observability)
103+
- `onRetryScheduled?`: Callback when a retry is scheduled (observability)
101104

102105
Returns:
103106
- `store`: Persistence store
104107
- `broadcast`: Cross-tab messaging
105108
- `senderLoop`: Processing loop
106109
- `enqueue`: Enqueue function
107110
- `undo`: Undo function
111+
- `retry`: Retry a failed item
112+
- `close`: Close store and broadcast resources
108113

109114
### Transport Interface
110115

@@ -171,6 +176,67 @@ Calculates next retry timestamp with exponential backoff and jitter.
171176
- `BACKOFF_CAP_MS`: 60000ms - Maximum backoff delay
172177
- `JITTER_RANGE_MS`: 250ms - Jitter range
173178

179+
## Error Recovery
180+
181+
### Failed Items
182+
183+
Items that reach `failed` status (validation errors, permanent failures, or max attempts exceeded) remain in the store for inspection. To recover:
184+
185+
```typescript
186+
// Retry a failed item (resets to pending with immediate retry)
187+
const result = await outbox.retry(itemId);
188+
if (result.success) {
189+
console.log('Retrying:', result.item);
190+
}
191+
192+
// Or delete the failed item
193+
await outbox.store.delete(itemId);
194+
```
195+
196+
### Auth-Blocked Items
197+
198+
Items blocked on authentication automatically resume when `store.resumeBlockedAuth()` is called:
199+
200+
```typescript
201+
// Call when user re-authenticates
202+
await outbox.store.resumeBlockedAuth(userScope, Date.now());
203+
```
204+
205+
### Observability
206+
207+
Use callbacks to monitor retry behavior without modifying the library:
208+
209+
```typescript
210+
const outbox = createOutbox({
211+
// ... other options
212+
onItemProcessed: (item, outcome) => {
213+
console.log(`Item ${item.id}: ${outcome}`);
214+
},
215+
onRetryScheduled: (item, delayMs) => {
216+
if (item.attemptCount > 5) {
217+
console.warn(`Item ${item.id} has failed ${item.attemptCount} times`);
218+
}
219+
},
220+
});
221+
```
222+
223+
## Cleanup
224+
225+
Call `close()` when done (e.g., on user sign-out):
226+
227+
```typescript
228+
outbox.close();
229+
```
230+
231+
For complete cleanup including data deletion:
232+
233+
```typescript
234+
import { deleteOutboxDatabase } from '@append/outbox';
235+
236+
outbox.close();
237+
await deleteOutboxDatabase(userScope);
238+
```
239+
174240
## Browser Support
175241

176242
- Chrome desktop
@@ -181,6 +247,21 @@ Requires:
181247
- BroadcastChannel
182248
- Web Locks API (optional, falls back to IDB lease)
183249

250+
## Publishing for External Use
251+
252+
This package currently exports TypeScript directly for monorepo use. For external publishing, configure a build step:
253+
254+
```json
255+
{
256+
"exports": {
257+
".": {
258+
"types": "./dist/index.d.ts",
259+
"default": "./dist/index.js"
260+
}
261+
}
262+
}
263+
```
264+
184265
## License
185266

186267
Private - Internal use only.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import { createOutbox } from '../create-outbox';
3+
import type { Transport, TransportResult } from '../transport';
4+
import type { Clock, OutboxItem } from '../types';
5+
6+
// =============================================================================
7+
// Test Types
8+
// =============================================================================
9+
10+
interface TestCommand {
11+
type: 'test_command';
12+
request: { data: string };
13+
}
14+
15+
interface TestResult {
16+
resultId: string;
17+
}
18+
19+
interface TestBroadcastResult {
20+
itemId: string;
21+
}
22+
23+
// =============================================================================
24+
// Test Helpers
25+
// =============================================================================
26+
27+
function createMockClock(initialTime = 1000): Clock & { advance: (ms: number) => void } {
28+
let time = initialTime;
29+
return {
30+
now: () => time,
31+
advance: (ms: number) => {
32+
time += ms;
33+
},
34+
};
35+
}
36+
37+
function createMockTransport(): Transport<TestCommand, TestResult> & {
38+
setResult: (result: TransportResult<TestResult>) => void;
39+
} {
40+
let result: TransportResult<TestResult> = { outcome: 'success', result: { resultId: 'result-123' } };
41+
return {
42+
setResult: (r: TransportResult<TestResult>) => {
43+
result = r;
44+
},
45+
execute: async () => result,
46+
};
47+
}
48+
49+
// =============================================================================
50+
// Tests
51+
// =============================================================================
52+
53+
describe('createOutbox', () => {
54+
let clock: ReturnType<typeof createMockClock>;
55+
let transport: ReturnType<typeof createMockTransport>;
56+
const userScope = 'user-123';
57+
58+
beforeEach(() => {
59+
clock = createMockClock(1000);
60+
transport = createMockTransport();
61+
});
62+
63+
function createTestOutbox() {
64+
// We need to monkey-patch the outbox to use our mock store/broadcast
65+
// Since createOutbox creates its own store/broadcast, we test via integration
66+
return createOutbox<TestCommand, TestResult, { data: string }, TestBroadcastResult>({
67+
userScope,
68+
transport,
69+
createCommand: (opts) => ({ type: 'test_command', request: { data: opts.data } }),
70+
createResultPayload: (item) => ({ itemId: item.id }),
71+
clock,
72+
});
73+
}
74+
75+
describe('retry()', () => {
76+
it('returns success and resets failed item to pending', async () => {
77+
const outbox = createTestOutbox();
78+
79+
// Enqueue an item
80+
const { item } = await outbox.enqueue({ data: 'test' });
81+
82+
// Manually mark it as failed by accessing the store
83+
const failedItem: OutboxItem<TestCommand> = {
84+
...item,
85+
status: 'failed',
86+
lastError: { message: 'Test error' },
87+
};
88+
await outbox.store.put(failedItem);
89+
90+
// Retry the item
91+
const result = await outbox.retry(item.id);
92+
93+
expect(result.success).toBe(true);
94+
expect(result.item?.status).toBe('pending');
95+
expect(result.item?.attemptCount).toBe(0);
96+
expect(result.item?.lastError).toBeUndefined();
97+
});
98+
99+
it('returns error when item not found', async () => {
100+
const outbox = createTestOutbox();
101+
102+
const result = await outbox.retry('non-existent');
103+
104+
expect(result.success).toBe(false);
105+
expect(result.error).toBe('Item not found');
106+
});
107+
108+
it('returns error when item belongs to different user', async () => {
109+
const outbox = createTestOutbox();
110+
111+
// Create an item with different userScope
112+
const item: OutboxItem<TestCommand> = {
113+
id: 'item-1',
114+
userScope: 'other-user',
115+
command: { type: 'test_command', request: { data: 'test' } },
116+
createdAt: 1000,
117+
updatedAt: 1000,
118+
undoUntil: 2000,
119+
status: 'failed',
120+
attemptCount: 0,
121+
nextAttemptAt: 1000,
122+
};
123+
await outbox.store.put(item);
124+
125+
const result = await outbox.retry('item-1');
126+
127+
expect(result.success).toBe(false);
128+
expect(result.error).toBe('Item belongs to different user');
129+
});
130+
131+
it('returns error when item is not in failed status', async () => {
132+
const outbox = createTestOutbox();
133+
134+
// Enqueue creates a pending item
135+
const { item } = await outbox.enqueue({ data: 'test' });
136+
137+
const result = await outbox.retry(item.id);
138+
139+
expect(result.success).toBe(false);
140+
expect(result.error).toBe("Cannot retry item with status 'pending'");
141+
});
142+
143+
it('broadcasts outbox_changed and kick after retry', async () => {
144+
const outbox = createTestOutbox();
145+
146+
// Enqueue and fail an item
147+
const { item } = await outbox.enqueue({ data: 'test' });
148+
await outbox.store.put({ ...item, status: 'failed' });
149+
150+
// Clear broadcast messages from enqueue
151+
const messagesBefore = outbox.broadcast.subscribe(() => {});
152+
messagesBefore();
153+
154+
await outbox.retry(item.id);
155+
156+
// Check via subscription that messages were published
157+
// Since we can't easily check messages on real broadcast, just verify success
158+
const result = await outbox.retry(item.id);
159+
// Second retry should fail since item is now pending
160+
expect(result.success).toBe(false);
161+
expect(result.error).toBe("Cannot retry item with status 'pending'");
162+
});
163+
});
164+
165+
describe('close()', () => {
166+
it('closes the store', () => {
167+
const outbox = createTestOutbox();
168+
169+
// Should not throw
170+
expect(() => outbox.close()).not.toThrow();
171+
});
172+
173+
it('can be called multiple times without error', () => {
174+
const outbox = createTestOutbox();
175+
176+
outbox.close();
177+
outbox.close();
178+
outbox.close();
179+
180+
// Should not throw
181+
expect(true).toBe(true);
182+
});
183+
});
184+
185+
describe('integration', () => {
186+
it('exposes all expected methods', () => {
187+
const outbox = createTestOutbox();
188+
189+
expect(outbox.store).toBeDefined();
190+
expect(outbox.broadcast).toBeDefined();
191+
expect(typeof outbox.enqueue).toBe('function');
192+
expect(typeof outbox.undo).toBe('function');
193+
expect(typeof outbox.retry).toBe('function');
194+
expect(typeof outbox.close).toBe('function');
195+
expect(outbox.senderLoop).toBeDefined();
196+
expect(typeof outbox.senderLoop.processOnce).toBe('function');
197+
expect(typeof outbox.senderLoop.getNextDueTime).toBe('function');
198+
});
199+
});
200+
});

0 commit comments

Comments
 (0)