|
|
@ -367,7 +367,7 @@ interface PreviousClaim { |
|
|
|
"version": "1.0.0" |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1703980800_xyz789_ghi01234", |
|
|
|
"jwtId": "1703980800_xyz789_0badf00d", |
|
|
|
"claimType": "project_update", |
|
|
|
"claimData": { |
|
|
|
"status": "in_progress", |
|
|
@ -384,7 +384,7 @@ interface PreviousClaim { |
|
|
|
"hitLimit": false, |
|
|
|
"pagination": { |
|
|
|
"hasMore": true, |
|
|
|
"nextAfterId": "1704153600_mno345_pqr67890" |
|
|
|
"nextAfterId": "1704153600_mno345_0badf00d" |
|
|
|
} |
|
|
|
} |
|
|
|
``` |
|
|
@ -525,7 +525,7 @@ function compareJwtIds(a: string, b: string): number { |
|
|
|
return a.localeCompare(b); |
|
|
|
} |
|
|
|
|
|
|
|
// Example: "1704067200_abc123_def45678" < "1704153600_xyz789_ghi01234" |
|
|
|
// Example: "1704067200_abc123_def45678" < "1704153600_xyz789_0badf00d" |
|
|
|
``` |
|
|
|
|
|
|
|
**Eventual Consistency Bounds**: |
|
|
@ -553,7 +553,7 @@ X-Idempotency-Key: {uuid} |
|
|
|
**Request Body**: |
|
|
|
```json |
|
|
|
{ |
|
|
|
"acknowledgedJwtIds": ["1704067200_abc123_def45678", "1704153600_mno345_pqr67890"], |
|
|
|
"acknowledgedJwtIds": ["1704067200_abc123_def45678", "1704153600_mno345_0badf00d"], |
|
|
|
"acknowledgedAt": "2025-01-01T12:00:00Z", |
|
|
|
"clientVersion": "TimeSafari-DailyNotificationPlugin/1.0.0" |
|
|
|
} |
|
|
@ -1128,7 +1128,9 @@ document.addEventListener('visibilitychange', () => { |
|
|
|
const lastPoll = localStorage.getItem('lastPollTimestamp'); |
|
|
|
const now = Date.now(); |
|
|
|
if (now - parseInt(lastPoll) > 3600000) { // 1 hour |
|
|
|
pollStarredProjects(); |
|
|
|
pollStarredProjects().then(() => { |
|
|
|
localStorage.setItem('lastPollTimestamp', now.toString()); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
@ -1147,13 +1149,15 @@ document.addEventListener('visibilitychange', () => { |
|
|
|
↓ |
|
|
|
[Make API Call] ← [Valid Config] ← [Has Starred Projects] ← [Has Last Ack ID] |
|
|
|
↓ ↓ ↓ ↓ |
|
|
|
[Network Error] [Parse Response] [Process Results] [Update Watermark] |
|
|
|
[Network Error] [Parse Response] [Process Results] [Generate Notifications] |
|
|
|
↓ ↓ ↓ ↓ |
|
|
|
[Retry Logic] [Schedule Delivery] [Success] [Acknowledge Delivery] |
|
|
|
↓ ↓ ↓ ↓ |
|
|
|
[Exponential Backoff] [Acknowledge Delivery] [End] [Update Watermark] |
|
|
|
↓ ↓ ↓ ↓ |
|
|
|
[Retry Logic] [Generate Notifications] [Success] [Commit State] |
|
|
|
[Max Retries] [Update Watermark] [End] [Commit State] |
|
|
|
↓ ↓ ↓ ↓ |
|
|
|
[Exponential Backoff] [Schedule Delivery] [End] [End] |
|
|
|
↓ ↓ |
|
|
|
[Max Retries] [End] |
|
|
|
[End] [Commit State] [End] [End] |
|
|
|
↓ |
|
|
|
[End] |
|
|
|
``` |
|
|
@ -1164,8 +1168,8 @@ document.addEventListener('visibilitychange', () => { |
|
|
|
3. **Check Last Ack ID**: If `lastAckedStarredPlanChangesJwtId` is null, run Bootstrap Watermark; then continue |
|
|
|
4. **Make API Call**: Execute authenticated POST request |
|
|
|
5. **Process Results**: Parse response and extract change count |
|
|
|
6. **Update Watermark**: Advance watermark only after successful delivery AND acknowledgment |
|
|
|
7. **Generate Notifications**: Create user notifications for changes |
|
|
|
6. **Generate Notifications**: Create user notifications for changes |
|
|
|
7. **Update Watermark**: Advance watermark only after successful delivery AND acknowledgment |
|
|
|
|
|
|
|
#### Watermark Bootstrap Path |
|
|
|
|
|
|
@ -1390,10 +1394,10 @@ class OutboxPressureManager { |
|
|
|
|
|
|
|
**Atomic Transaction Pattern**: |
|
|
|
```sql |
|
|
|
-- Phase 1: Atomic commit of watermark + outbox |
|
|
|
-- Phase 1: Atomic commit of outbox only (watermark stays unchanged) |
|
|
|
BEGIN TRANSACTION; |
|
|
|
INSERT INTO notification_outbox (jwt_id, content) VALUES (?, ?); |
|
|
|
UPDATE settings SET lastAckedStarredPlanChangesJwtId = ? WHERE accountDid = ?; |
|
|
|
-- Do NOT update watermark here - it advances only after delivery + acknowledgment |
|
|
|
COMMIT; |
|
|
|
``` |
|
|
|
|
|
|
@ -1471,7 +1475,7 @@ async function processPollingResults(results: PollingResult[]): Promise<void> { |
|
|
|
} |
|
|
|
|
|
|
|
// 3. Dispatcher delivers notifications |
|
|
|
await notificationDispatcher.processOutbox(); |
|
|
|
const { deliveredJwtIds, latestJwtId } = await notificationDispatcher.processOutbox(); |
|
|
|
|
|
|
|
// 4. After successful delivery, call acknowledgment endpoint |
|
|
|
await acknowledgeDeliveredNotifications(deliveredJwtIds); |
|
|
@ -1507,7 +1511,7 @@ UPDATE notification_outbox SET delivered_at = datetime('now'), acknowledged_at = |
|
|
|
``` |
|
|
|
|
|
|
|
**Recovery Rules**: |
|
|
|
- **Crash After Watermark Update**: On restart, check `notification_pending` table for uncommitted notifications |
|
|
|
- **Crash After Watermark Update**: On restart, check `notification_outbox` table for uncommitted notifications |
|
|
|
- **Crash Before Watermark Update**: Safe to retry - no state change occurred |
|
|
|
- **Partial Notification Failure**: Rollback watermark update, retry entire transaction |
|
|
|
- **Acknowledgment Endpoint**: Call `POST /api/v2/plans/acknowledge` after successful notification delivery |
|
|
@ -1515,13 +1519,13 @@ UPDATE notification_outbox SET delivered_at = datetime('now'), acknowledged_at = |
|
|
|
**Recovery Implementation**: |
|
|
|
```typescript |
|
|
|
async function recoverPendingNotifications(): Promise<void> { |
|
|
|
const pending = await db.query('SELECT * FROM notification_pending WHERE created_at < ?', |
|
|
|
const pending = await db.query('SELECT * FROM notification_outbox WHERE created_at < ?', |
|
|
|
[Date.now() - 300000]); // 5 minutes ago |
|
|
|
|
|
|
|
for (const notification of pending) { |
|
|
|
try { |
|
|
|
await scheduleNotification(notification.content); |
|
|
|
await db.query('DELETE FROM notification_pending WHERE id = ?', [notification.id]); |
|
|
|
await db.query('DELETE FROM notification_outbox WHERE id = ?', [notification.id]); |
|
|
|
} catch (error) { |
|
|
|
// Log error, will retry on next recovery cycle |
|
|
|
console.error('Failed to recover notification:', error); |
|
|
@ -1578,7 +1582,7 @@ Body: "You have {count} new updates in your starred projects" |
|
|
|
|
|
|
|
**Deep Link Routes**: |
|
|
|
``` |
|
|
|
timesafari://projects/updates?jwtIds=1704067200_abc123_def45678,1704153600_mno345_pqr67890 |
|
|
|
timesafari://projects/updates?jwtIds=1704067200_abc123_def45678,1704153600_mno345_0badf00d |
|
|
|
timesafari://projects/{projectId}/details?jwtId=1704067200_abc123_def45678 |
|
|
|
timesafari://notifications/starred-projects |
|
|
|
timesafari://projects/updates?shortlink=abc123def456789 |
|
|
@ -1613,7 +1617,7 @@ function validateDeepLinkParams(params: any): DeepLinkValidation { |
|
|
|
```typescript |
|
|
|
// Server generates shortlink for large result sets |
|
|
|
const shortlink = await generateShortlink({ |
|
|
|
jwtIds: ['1704067200_abc123_def45678', '1704153600_mno345_pqr67890', /* ... 50 more */], |
|
|
|
jwtIds: ['1704067200_abc123_def45678', '1704153600_mno345_0badf00d', /* ... 50 more */], |
|
|
|
expiresAt: Date.now() + 86400000 // 24 hours |
|
|
|
}); |
|
|
|
|
|
|
@ -2004,125 +2008,6 @@ const PII_REDACTION_PATTERNS = [ |
|
|
|
|
|
|
|
### Testing Artifacts |
|
|
|
|
|
|
|
#### Mock Fixtures |
|
|
|
|
|
|
|
**Empty Response**: |
|
|
|
```json |
|
|
|
{ |
|
|
|
"data": [], |
|
|
|
"hitLimit": false, |
|
|
|
"pagination": { |
|
|
|
"hasMore": false, |
|
|
|
"nextAfterId": null |
|
|
|
} |
|
|
|
} |
|
|
|
``` |
|
|
|
|
|
|
|
**Small Response (3 items)**: |
|
|
|
```json |
|
|
|
{ |
|
|
|
"data": [ |
|
|
|
{ |
|
|
|
"planSummary": { |
|
|
|
"jwtId": "1704067200_abc123_def45678", |
|
|
|
"handleId": "test_project_1", |
|
|
|
"name": "Test Project 1", |
|
|
|
"description": "First test project" |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1703980800_xyz789_ghi01234", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
}, |
|
|
|
{ |
|
|
|
"planSummary": { |
|
|
|
"jwtId": "1704153600_mno345_pqr67890", |
|
|
|
"handleId": "test_project_2", |
|
|
|
"name": "Test Project 2", |
|
|
|
"description": "Second test project" |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1704067200_stu901_vwx23456", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
}, |
|
|
|
{ |
|
|
|
"planSummary": { |
|
|
|
"jwtId": "1704240000_new123_0badf00d", |
|
|
|
"handleId": "test_project_3", |
|
|
|
"name": "Test Project 3", |
|
|
|
"description": "Third test project" |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1704153600_old456_1cafebad", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
} |
|
|
|
], |
|
|
|
"hitLimit": false, |
|
|
|
"pagination": { |
|
|
|
"hasMore": false, |
|
|
|
"nextAfterId": null |
|
|
|
} |
|
|
|
} |
|
|
|
``` |
|
|
|
|
|
|
|
**Paginated Response**: |
|
|
|
```json |
|
|
|
{ |
|
|
|
"data": [...], // 100 items |
|
|
|
"hitLimit": true, |
|
|
|
"pagination": { |
|
|
|
"hasMore": true, |
|
|
|
"nextAfterId": "1704153600_mno345_pqr67890" |
|
|
|
} |
|
|
|
} |
|
|
|
``` |
|
|
|
|
|
|
|
**Rate Limited Response (canonical format)**: |
|
|
|
```json |
|
|
|
{ |
|
|
|
"error": "Rate limit exceeded", |
|
|
|
"code": "RATE_LIMIT_EXCEEDED", |
|
|
|
"retryAfter": 60, |
|
|
|
"details": { |
|
|
|
"limit": 100, |
|
|
|
"window": "1m", |
|
|
|
"remaining": 0, |
|
|
|
"resetAt": "2024-01-01T12:01:00Z" |
|
|
|
}, |
|
|
|
"requestId": "req_jkl012" |
|
|
|
} |
|
|
|
``` |
|
|
|
|
|
|
|
**Contract Tests**: |
|
|
|
```typescript |
|
|
|
// JWT ID comparison helper |
|
|
|
function compareJwtIds(a: string, b: string): number { |
|
|
|
return a.localeCompare(b); // Lexicographic comparison for fixed-width format |
|
|
|
} |
|
|
|
|
|
|
|
describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
test('should maintain JWT ID ordering', () => { |
|
|
|
const response = mockPaginatedResponse(); |
|
|
|
const jwtIds = response.data.map(item => item.planSummary.jwtId); |
|
|
|
const sortedIds = [...jwtIds].sort(compareJwtIds); |
|
|
|
expect(jwtIds).toEqual(sortedIds); |
|
|
|
}); |
|
|
|
|
|
|
|
test('should handle watermark movement correctly', () => { |
|
|
|
const initialWatermark = '1704067200_abc123_def45678'; |
|
|
|
const response = mockSmallResponse(); |
|
|
|
const newWatermark = getNewWatermark(response); |
|
|
|
expect(compareJwtIds(newWatermark, initialWatermark)).toBeGreaterThan(0); |
|
|
|
}); |
|
|
|
|
|
|
|
test('should respect pagination limits', () => { |
|
|
|
const response = mockPaginatedResponse(); |
|
|
|
expect(response.data.length).toBeLessThanOrEqual(100); |
|
|
|
}); |
|
|
|
}); |
|
|
|
``` |
|
|
|
|
|
|
|
#### Testing Fixtures & SLAs |
|
|
|
|
|
|
@ -2153,16 +2038,16 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
"issuerDid": "did:key:test_issuer_1", |
|
|
|
"agentDid": "did:key:test_agent_1", |
|
|
|
"locLat": 40.7128, |
|
|
|
"locLon": -74.0060, |
|
|
|
"locLon": -74.0060 |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1703980800_xyz789_ghi01234", |
|
|
|
"jwtId": "1703980800_xyz789_0badf00d", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
}, |
|
|
|
{ |
|
|
|
"planSummary": { |
|
|
|
"jwtId": "1704153600_mno345_pqr67890", |
|
|
|
"jwtId": "1704153600_mno345_0badf00d", |
|
|
|
"handleId": "test_project_2", |
|
|
|
"name": "Test Project 2", |
|
|
|
"description": "Second test project", |
|
|
@ -2172,7 +2057,7 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
"locLon": null |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1704067200_stu901_vwx23456", |
|
|
|
"jwtId": "1704067200_stu901_1cafebad", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
}, |
|
|
@ -2188,7 +2073,7 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
"locLon": -122.4194 |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1704153600_old456_1cafebad", |
|
|
|
"jwtId": "1704153600_old456_0badf00d", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
} |
|
|
@ -2205,12 +2090,12 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
```json |
|
|
|
{ |
|
|
|
"data": [ |
|
|
|
// ... 100 items with jwtIds from "1704067200_abc123_def45678" to "1704153600_xyz789_ghi01234" |
|
|
|
// ... 100 items with jwtIds from "1704067200_abc123_def45678" to "1704153600_xyz789_0badf00d" |
|
|
|
], |
|
|
|
"hitLimit": true, |
|
|
|
"pagination": { |
|
|
|
"hasMore": true, |
|
|
|
"nextAfterId": "1704240000_new123_0badf00d" |
|
|
|
"nextAfterId": "1704240000_new123_1cafebad" |
|
|
|
} |
|
|
|
} |
|
|
|
``` |
|
|
@ -2245,7 +2130,7 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
"locLon": null |
|
|
|
}, |
|
|
|
"previousClaim": { |
|
|
|
"jwtId": "1703980800_xyz789_ghi01234", |
|
|
|
"jwtId": "1703980800_xyz789_0badf00d", |
|
|
|
"claimType": "project_update" |
|
|
|
} |
|
|
|
} |
|
|
@ -2264,7 +2149,7 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
"data": [ |
|
|
|
{ |
|
|
|
"planSummary": { |
|
|
|
"jwtId": "1704153600_mno345_pqr67890", |
|
|
|
"jwtId": "1704153600_mno345_0badf00d", |
|
|
|
"handleId": "test_project_2", |
|
|
|
"locLat": null, |
|
|
|
"locLon": null |
|
|
@ -2275,7 +2160,7 @@ describe('StarredProjectsPolling Contract Tests', () => { |
|
|
|
"jwtId": "1704067200_abc123_def45678", |
|
|
|
"handleId": "test_project_1", |
|
|
|
"locLat": 40.7128, |
|
|
|
"locLon": -74.0060, |
|
|
|
"locLon": -74.0060 |
|
|
|
} |
|
|
|
} |
|
|
|
], |
|
|
|