A stateless, deterministic sync worker implementing the Declarative Reconciliation Loop (DRL) pattern to bidirectionally sync Google Calendar events and Linear issues.
This refactored implementation follows a strict Observe → Project → Diff → Actuate pattern:
- Observe: Fetch current snapshots from Linear and Google Calendar APIs
- Project: Create a canonical truth using deterministic, side-effect-free functions
- Diff: Compare canonical state with external reality to compute minimal operations
- Actuate: Execute idempotent API calls until external systems match canonical truth
- Stateless: No database required; all state lives in remote systems via UID linkages
- Deterministic: Pure functions ensure identical inputs always produce identical outputs
- Idempotent: Operations can be safely retried without side effects
- Minimal: Only necessary changes are made to external systems
interface GCalEvent {
id: string; // Primary key (never edited)
summary: string; // Prefix + human title
description?: string; // Freeform notes
start: { dateTime: string }; // ISO 8601 UTC
end: { dateTime: string }; // ISO 8601 UTC
extendedProperties?: {
private?: {
uid?: string; // Canonical UID linking to Linear
linearIssueId?: string; // Back-pointer to Linear issue
};
};
status: "confirmed" | "cancelled";
}
interface LinearIssue {
id: string; // Primary key
title: string; // Prefix + human title
description?: string; // Long notes + calendar metadata
state: "Triage" | "Scheduled" | "Done" | "Canceled" | "Failed";
targetDate?: string; // ISO 8601 datetime
// Note: Linear API doesn't support custom fields - use description metadata instead
}
The unified representation that bridges both systems:
interface CanonicalItem {
uid: string; // Stable identifier across systems
title: string; // Clean title (no prefix)
description?: string;
startTime?: string; // ISO 8601
endTime?: string; // ISO 8601
linearId?: string;
gcalId?: string;
linearState?: LinearIssue["state"];
phase: Phase; // Current lifecycle phase
lastModified: string; // For conflict resolution
}
The system recognizes 5 phases with specific transition rules:
Phase | Description | Triggers |
---|---|---|
eventOnly | GCal event without Linear match | New calendar event created |
linearOnly | Linear issue without GCal match | Issue marked as "Scheduled" |
active | Linked and syncing | Both systems have records |
completed | Linear marked done/canceled/failed | State change in Linear |
overdue | Past deadline but still active | >24h after event end |
From → To | Trigger | Action | Result Prefix |
---|---|---|---|
eventOnly → active | GCal event exists | Create Linear issue (Triage) | 📥 |
linearOnly → active | Linear state = "Scheduled" | Create GCal event | 📅 |
active → completed | Linear state = Done/Canceled/Failed | Update GCal title | ✅/🚫/❌ |
active → overdue | >24h past event end | Mark original as worked (⏳) + create new event | ⏳ + 📅 |
- 📥 Triage: New items from Google Calendar awaiting review
- 📅 Scheduled: Items scheduled for specific times
- ✅ Done: Completed items
- 🚫 Canceled: Canceled items
- ❌ Failed: Failed items
- ⏳ Worked: Original events that went overdue (shows work was done)
| active → active
| Title/time/description change | Sync metadata | (unchanged) |
The system uses two linking mechanisms:
- GCal → Linear:
extendedProperties.private.linearIssueId
in calendar events - Linear → GCal: Description metadata
<!-- calendar-sync --> GoogleCalEventId:xyz
in Linear issues
This approach works because:
- Google Calendar API supports
extendedProperties
for metadata storage - Linear API doesn't support custom fields, so we embed metadata in descriptions
- Both systems preserve their metadata across updates
- Title: Linear wins (source of truth for task names)
- Start/End Times: Calendar wins (source of truth for scheduling)
- Description: Newest
updatedAt
wins (simplified to Linear in current implementation)
src/
├── types.ts # Core data models and interfaces
├── projector.ts # Observe → Project (create canonical truth)
├── diff.ts # Project → Diff (compute operations)
├── actuator.ts # Diff → Actuate (execute operations)
├── worker.ts # Main sync orchestrator
├── main.ts # Entry point with configuration
├── dry-run.ts # Dry run mode for testing
└── *.test.ts # Comprehensive test suite
scripts/
├── check.ts # Environment configuration checker
├── validate.ts # API structure validator
├── setup.ts # Val Town setup guide
├── show-*.ts # Debug scripts for data inspection
├── api-validator.ts # API structure validation
├── val-town-config.ts # Val Town deployment helpers
└── README.md # Script documentation
# Check environment and configuration deno task check # Validate API structure deno task validate # Test sync logic with real data (no changes) deno task dry-run # Show setup guide for Val Town deno task setup # Run unit tests deno task test
deno task test
# Create .env file with your credentials cp .env.example .env # Edit .env with your actual API keys # Run sync deno task sync # Or run with dry-run mode first deno task dry-run
Create a .env
file with:
LINEAR_API_KEY="your-linear-api-key" LINEAR_TEAM_ID="your-team-id" GOOGLE_SERVICE_ACCOUNT_JSON="base64-encoded-service-account" GCAL_CALENDAR_ID="your-calendar-id"
# View Google Calendar events deno run --allow-env --allow-net scripts/show-gcal.ts # View Linear issues deno run --allow-env --allow-net scripts/show-linear.ts # View canonical projection deno run --allow-env --allow-net scripts/show-canonical.ts # Complete data flow analysis deno run --allow-env --allow-net scripts/show-all.ts
The worker is configured via environment variables:
LINEAR_API_KEY
: Linear API tokenLINEAR_TEAM_ID
: Linear team identifierGOOGLE_SERVICE_ACCOUNT_JSON
: Base64-encoded Google service account JSONGCAL_CALENDAR_ID
: Primary calendar IDGCAL_HISTORY_CALENDAR_ID
: (Optional) Calendar for overdue itemsTIMEZONE
: Timezone for operations (default: America/New_York)LOOKBACK_DAYS
: Days to look back (default: 2)LOOKAHEAD_DAYS
: Days to look ahead (default: 14)
- Latency: < 5 seconds for 10,000 items (per spec requirement)
- Memory: Stateless operation, minimal memory footprint
- Network: Batched API calls, efficient pagination
- Reliability: Idempotent operations survive failures
The test suite covers:
- Unit tests: Individual functions (types, projector, diff)
- Integration tests: Full sync workflows with mock APIs
- Determinism: Identical inputs produce identical outputs
- Idempotence: Operations can be safely retried
- Performance: Validates < 5s execution time
The DRL pattern makes it easy to:
- Add new external systems (e.g., Notion) by implementing new projectors/actuators
- Add new phase transitions by updating the diff engine
- Add new conflict resolution rules in the projector
- Add webhook triggers by replacing the cron scheduler
The original implementation has been moved to backup/
and key concepts have been preserved:
- Bidirectional sync logic
- State machine approach (now phase-based)
- Metadata embedding for linkage
- Prefix-based visual indicators
The new implementation is more robust, testable, and maintainable while preserving all original functionality.