Merge branch 'master' of ssh://173.199.124.46:222/trent_larson/daily-notification-plugin
This commit is contained in:
@@ -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] [Generate Notifications] [Success] [Commit State]
|
||||
[Retry Logic] [Schedule Delivery] [Success] [Acknowledge Delivery]
|
||||
↓ ↓ ↓ ↓
|
||||
[Exponential Backoff] [Schedule Delivery] [End] [End]
|
||||
↓ ↓
|
||||
[Max Retries] [End]
|
||||
[Exponential Backoff] [Acknowledge Delivery] [End] [Update Watermark]
|
||||
↓ ↓ ↓ ↓
|
||||
[Max Retries] [Update Watermark] [End] [Commit State]
|
||||
↓ ↓ ↓ ↓
|
||||
[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
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user