Linear Data Handling
Overview
Implement reliable data synchronization, backup, and consistency for Linear integrations. Covers full sync, incremental webhook sync, JSON/CSV export, consistency checks, and conflict resolution.
Prerequisites
@linear/sdk with API key configured
- Database for local storage (any ORM — Drizzle, Prisma, Knex)
- Understanding of eventual consistency
Instructions
Step 1: Data Model Schema
import { z } from "zod";
export const LinearIssueSchema = z.object({
id: z.string().uuid(),
identifier: z.string(),
title: z.string(),
description: z.string().nullable(),
priority: z.number().int().min(0).max(4),
estimate: z.number().nullable(),
stateId: z.string().uuid(),
stateName: z.string(),
stateType: z.string(),
teamId: z.string().uuid(),
teamKey: z.string(),
assigneeId: z.string().uuid().nullable(),
projectId: z.string().uuid().nullable(),
cycleId: z.string().uuid().nullable(),
parentId: z.string().uuid().nullable(),
dueDate: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
completedAt: z.string().nullable(),
canceledAt: z.string().nullable(),
syncedAt: z.string(),
});
export type LinearIssue = z.infer<typeof LinearIssueSchema>;
Step 2: Full Sync
Paginate through all issues, resolve relations, and upsert locally.
import { LinearClient } from "@linear/sdk";
interface SyncStats {
total: number;
created: number;
updated: number;
deleted: number;
errors: number;
}
async function fullSync(client: LinearClient, teamKey: string): Promise<SyncStats> {
const stats: SyncStats = { total: 0, created: 0, updated: 0, deleted: 0, errors: 0 };
const remoteIds = new Set<string>();
let cursor: string | undefined;
let hasNext = true;
while (hasNext) {
const result = await client.client.rawRequest(, { teamKey, cursor });
issues = result..;
( issue issues.) {
remoteIds.(issue.);
stats.++;
{
: = {
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
: issue..,
: issue..,
: issue..,
: issue..,
: issue..,
: issue.?. ?? ,
: issue.?. ?? ,
: issue.?. ?? ,
: issue.?. ?? ,
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
: ().(),
};
existing = db..(issue.);
(existing) {
db..(issue., mapped);
stats.++;
} {
db..(mapped);
stats.++;
}
} (error) {
stats.++;
.(, error);
}
}
hasNext = issues..;
cursor = issues..;
(hasNext) ( (r, ));
}
localIds = db..({ teamKey });
( localId localIds) {
(!remoteIds.(localId)) {
db..(localId);
stats.++;
}
}
.(, stats);
stats;
}
Step 3: Incremental Sync via Webhooks
async function processWebhookSync(event: {
action: "create" | "update" | "remove";
type: string;
data: any;
}) {
if (event.type !== "Issue") return;
const syncedAt = new Date().toISOString();
switch (event.action) {
case "create":
await db.issues.insert({
id: event.data.id,
identifier: event.data.identifier,
title: event.data.title,
description: event.data.description,
priority: event.data.priority,
estimate: event.data.estimate,
stateId: event.data.stateId ?? event.data.state?.id,
stateName: event.data.state?. ?? ,
: event..?. ?? ,
: event.. ?? event..?.,
: event..?. ?? ,
: event.. ?? ,
: event.. ?? ,
: event.. ?? ,
: event.. ?? ,
: event.. ?? ,
: event..,
: event..,
: event.. ?? ,
: event.. ?? ,
syncedAt,
});
;
:
db..(event.., {
...event.,
syncedAt,
});
;
:
db..(event..);
;
}
}
Step 4: Data Export / Backup
async function exportToJson(client: LinearClient, outputDir: string) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const teams = await client.teams();
const backup = {
exportedAt: new Date().toISOString(),
version: "1.0",
teams: teams.nodes.map(t => ({ id: t.id, key: t.key, name: t.name })),
projects: [] as any[],
issues: [] as any[],
};
const projects = await client.projects();
backup.projects = projects.nodes.map(p => ({
id: p.id, name: p., : p.,
: p., : p.,
}));
( team teams.) {
: | ;
hasNext = ;
(hasNext) {
result = client.({
: ,
: cursor,
: { : { : { : team. } } },
});
( issue result.) {
backup..({
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
: issue.,
});
}
hasNext = result..;
cursor = result..;
(hasNext) ( (r, ));
}
}
path = ;
fs.(path, .(backup, , ));
.();
}
Step 5: Consistency Check
async function checkConsistency(client: LinearClient, teamKey: string): Promise<{
missing: string[];
stale: string[];
orphaned: string[];
}> {
const remote = await client.issues({
first: 50,
filter: { team: { key: { eq: teamKey } } },
orderBy: "updatedAt",
});
const missing: string[] = [];
const stale: string[] = [];
for (const issue of remote.nodes) {
const local = await db.issues.findById(issue.id);
if (!local) {
missing.push(issue.identifier);
} else if (local.updatedAt < issue.updatedAt) {
stale.push(issue.identifier);
}
}
: [] = [];
localSample = db..();
( local localSample) {
{
client.(local.);
} {
orphaned.(local.);
}
}
result = { missing, stale, orphaned };
.();
(missing. > || stale. > ) {
.();
(client, teamKey);
}
result;
}
Step 6: Conflict Resolution
type ConflictStrategy = "remote-wins" | "local-wins" | "merge" | "manual";
interface ConflictResult {
resolved: boolean;
strategy: ConflictStrategy;
winner: "local" | "remote" | "merged";
}
function resolveConflict(
local: LinearIssue,
remote: any,
strategy: ConflictStrategy,
mergeFields?: string[]
): ConflictResult {
switch (strategy) {
case "remote-wins":
db.issues.update(remote.id, { ...remote, syncedAt: new Date().toISOString() });
return { resolved: true, strategy, winner: "remote" };
case "local-wins":
return { resolved: true, strategy, winner: };
:
merged = { ...local };
( field mergeFields ?? [, , ]) {
(merged )[field] = remote[field];
}
merged. = ().();
db..(remote., merged);
{ : , strategy, : };
:
();
}
}
Error Handling
| Issue | Cause | Solution |
|---|
| Sync timeout | Too many records | Use smaller page sizes, add delays |
| Conflict detected | Concurrent edits | Apply conflict resolution strategy |
| Stale data | Missed webhook events | Trigger full sync via consistency check |
| Export failed | Rate limit during backup | Add 100ms delay between pagination calls |
| Duplicate entries | Webhook retry without dedup | Deduplicate by Linear-Delivery header |
Resources