@useairfoil/producer-linear 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +71 -0
  3. package/dist/index.d.mts +4027 -0
  4. package/dist/index.mjs +3 -0
  5. package/dist/main.d.mts +1 -0
  6. package/dist/main.mjs +1305 -0
  7. package/dist/main.mjs.map +1 -0
  8. package/dist/manifest.d.mts +114 -0
  9. package/dist/manifest.mjs +161 -0
  10. package/dist/manifest.mjs.map +1 -0
  11. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  12. package/dist/src-BbzySPR7.mjs +1688 -0
  13. package/dist/src-BbzySPR7.mjs.map +1 -0
  14. package/package.json +46 -8
  15. package/src/client/client.ts +200 -0
  16. package/src/connector.ts +151 -0
  17. package/src/create-table.ts +10 -0
  18. package/src/demo/index.ts +137 -0
  19. package/src/demo/initiatives.ts +116 -0
  20. package/src/demo/issues.ts +320 -0
  21. package/src/demo/projects.ts +215 -0
  22. package/src/demo/setup.ts +151 -0
  23. package/src/demo/shared.ts +122 -0
  24. package/src/demo/teams.ts +173 -0
  25. package/src/index.ts +83 -0
  26. package/src/main.ts +28 -0
  27. package/src/manifest.ts +58 -0
  28. package/src/resources/comments.ts +32 -0
  29. package/src/resources/customers.ts +105 -0
  30. package/src/resources/documents.ts +31 -0
  31. package/src/resources/initiatives.ts +136 -0
  32. package/src/resources/issue-history.ts +119 -0
  33. package/src/resources/issues.ts +131 -0
  34. package/src/resources/pages.ts +264 -0
  35. package/src/resources/projects.ts +161 -0
  36. package/src/resources/releases.ts +114 -0
  37. package/src/resources/teams.ts +140 -0
  38. package/src/sandbox.ts +35 -0
  39. package/src/schemas/comments.ts +52 -0
  40. package/src/schemas/customers.ts +96 -0
  41. package/src/schemas/documents.ts +40 -0
  42. package/src/schemas/initiatives.ts +155 -0
  43. package/src/schemas/issues.ts +262 -0
  44. package/src/schemas/projects.ts +210 -0
  45. package/src/schemas/releases.ts +132 -0
  46. package/src/schemas/shared.ts +68 -0
  47. package/src/schemas/teams.ts +177 -0
  48. package/src/start.ts +47 -0
  49. package/src/tables.ts +83 -0
  50. package/src/webhook/route.ts +50 -0
  51. package/src/webhook/signature.ts +28 -0
@@ -0,0 +1,136 @@
1
+ import { Resource } from "@useairfoil/connector-kit";
2
+
3
+ import type { LinearClientService } from "../client/client";
4
+
5
+ import {
6
+ InitiativeLabelObjectSchema,
7
+ InitiativeLabelSchema,
8
+ InitiativeObjectSchema,
9
+ InitiativeRelationObjectSchema,
10
+ InitiativeRelationSchema,
11
+ InitiativeSchema,
12
+ InitiativeToProjectObjectSchema,
13
+ InitiativeToProjectSchema,
14
+ InitiativeUpdateObjectSchema,
15
+ InitiativeUpdateSchema,
16
+ } from "../schemas/initiatives";
17
+ import {
18
+ backfillList,
19
+ changesList,
20
+ checkList,
21
+ listSources,
22
+ listSpec,
23
+ rowsOf,
24
+ webhookList,
25
+ } from "./pages";
26
+
27
+ const initiativeSpec = listSpec({
28
+ connection: "initiatives",
29
+ single: "initiative",
30
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
31
+ name description content url icon color status health
32
+ health_updated_at: healthUpdatedAt priority owner_id: owner { id } creator_id: creator { id }
33
+ parent_initiative_id: parentInitiative { id } target_date: targetDate
34
+ started_at: startedAt completed_at: completedAt canceled_at: canceledAt
35
+ label_ids: labelIds visibility trashed`,
36
+ item: InitiativeObjectSchema,
37
+ updatedAt: (initiative) => initiative.updated_at,
38
+ rows: rowsOf((initiative) => ({
39
+ ...initiative,
40
+ version: initiative.updated_at,
41
+ _deleted: false,
42
+ })),
43
+ });
44
+
45
+ export const makeInitiatives = (client: LinearClientService) =>
46
+ Resource.entity({
47
+ name: "initiatives",
48
+ rowSchema: InitiativeSchema,
49
+ version: "version",
50
+
51
+ check: checkList(client, initiativeSpec),
52
+ backfill: backfillList(client, initiativeSpec),
53
+ changes: changesList(client, initiativeSpec),
54
+ webhook: webhookList(client, initiativeSpec),
55
+ });
56
+
57
+ const initiativeUpdateSpec = listSpec({
58
+ connection: "initiativeUpdates",
59
+ single: "initiativeUpdate",
60
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
61
+ initiative_id: initiative { id } user_id: user { id } body health url
62
+ edited_at: editedAt comment_count: commentCount`,
63
+ item: InitiativeUpdateObjectSchema,
64
+ updatedAt: (update) => update.updated_at,
65
+ rows: rowsOf((update) => ({ ...update, version: update.updated_at, _deleted: false })),
66
+ });
67
+
68
+ export const makeInitiativeUpdates = (client: LinearClientService) =>
69
+ Resource.entity({
70
+ name: "initiative_updates",
71
+ rowSchema: InitiativeUpdateSchema,
72
+ version: "version",
73
+
74
+ check: checkList(client, initiativeUpdateSpec),
75
+ backfill: backfillList(client, initiativeUpdateSpec),
76
+ changes: changesList(client, initiativeUpdateSpec),
77
+ webhook: webhookList(client, initiativeUpdateSpec),
78
+ });
79
+
80
+ const initiativeLabelSpec = listSpec({
81
+ connection: "initiativeLabels",
82
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
83
+ name description color is_group: isGroup parent_id: parent { id }
84
+ creator_id: creator { id } retired_at: retiredAt`,
85
+ item: InitiativeLabelObjectSchema,
86
+ updatedAt: (label) => label.updated_at,
87
+ rows: rowsOf((label) => ({ ...label, version: label.updated_at })),
88
+ });
89
+
90
+ export const makeInitiativeLabels = (client: LinearClientService) =>
91
+ Resource.entity({
92
+ name: "initiative_labels",
93
+ rowSchema: InitiativeLabelSchema,
94
+ version: "version",
95
+ ...listSources(client, initiativeLabelSpec),
96
+ });
97
+
98
+ const initiativeRelationSpec = listSpec({
99
+ connection: "initiativeRelations",
100
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
101
+ initiative_id: initiative { id } related_initiative_id: relatedInitiative { id }
102
+ sort_order: sortOrder user_id: user { id }`,
103
+ item: InitiativeRelationObjectSchema,
104
+ updatedAt: (relation) => relation.updated_at,
105
+ rows: rowsOf((relation) => ({ ...relation, version: relation.updated_at })),
106
+ });
107
+
108
+ export const makeInitiativeRelations = (client: LinearClientService) =>
109
+ Resource.entity({
110
+ name: "initiative_relations",
111
+ rowSchema: InitiativeRelationSchema,
112
+ version: "version",
113
+
114
+ check: checkList(client, initiativeRelationSpec),
115
+ backfill: backfillList(client, initiativeRelationSpec),
116
+ changes: changesList(client, initiativeRelationSpec),
117
+ });
118
+
119
+ const initiativeToProjectSpec = listSpec({
120
+ connection: "initiativeToProjects",
121
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt initiative_id: initiative { id } project_id: project { id } sort_order: sortOrder`,
122
+ item: InitiativeToProjectObjectSchema,
123
+ updatedAt: (link) => link.updated_at,
124
+ rows: rowsOf((link) => ({ ...link, version: link.updated_at })),
125
+ });
126
+
127
+ export const makeInitiativeToProjects = (client: LinearClientService) =>
128
+ Resource.entity({
129
+ name: "initiative_to_projects",
130
+ rowSchema: InitiativeToProjectSchema,
131
+ version: "version",
132
+
133
+ check: checkList(client, initiativeToProjectSpec),
134
+ backfill: backfillList(client, initiativeToProjectSpec),
135
+ changes: changesList(client, initiativeToProjectSpec),
136
+ });
@@ -0,0 +1,119 @@
1
+ import { type ConnectorError, Resource } from "@useairfoil/connector-kit";
2
+ import { Effect, Option, Schema, Stream } from "effect";
3
+
4
+ import type { LinearClientService } from "../client/client";
5
+
6
+ import { type IssueHistory, IssueHistoryObjectSchema, IssueHistorySchema } from "../schemas/issues";
7
+ import { Timestamp } from "../schemas/shared";
8
+ import { backfillList, changesList, checkList, listSpec } from "./pages";
9
+
10
+ const historyFields = `
11
+ id created_at: createdAt updated_at: updatedAt archived_at: archivedAt actor_id: actorId
12
+ from_state_id: fromStateId to_state_id: toStateId
13
+ from_assignee_id: fromAssigneeId to_assignee_id: toAssigneeId
14
+ from_cycle_id: fromCycleId to_cycle_id: toCycleId
15
+ from_project_id: fromProjectId to_project_id: toProjectId
16
+ from_parent_id: fromParentId to_parent_id: toParentId from_team_id: fromTeamId to_team_id: toTeamId
17
+ from_priority: fromPriority to_priority: toPriority
18
+ from_estimate: fromEstimate to_estimate: toEstimate
19
+ from_due_date: fromDueDate to_due_date: toDueDate from_title: fromTitle to_title: toTitle
20
+ added_label_ids: addedLabelIds removed_label_ids: removedLabelIds
21
+ attachment_id: attachmentId customer_need_id: customerNeedId
22
+ to_converted_project_id: toConvertedProjectId updated_description: updatedDescription
23
+ archived trashed auto_archived: autoArchived auto_closed: autoClosed`;
24
+
25
+ const HistoryPageSchema = Schema.Struct({
26
+ nodes: Schema.Array(IssueHistoryObjectSchema),
27
+ pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }),
28
+ });
29
+
30
+ type HistoryPage = typeof HistoryPageSchema.Type;
31
+
32
+ const IssueWithHistorySchema = Schema.Struct({
33
+ id: Schema.String,
34
+ updated_at: Timestamp,
35
+ history: HistoryPageSchema,
36
+ });
37
+
38
+ const nextCursor = ({ pageInfo }: HistoryPage) =>
39
+ pageInfo.hasNextPage ? Option.fromNullishOr(pageInfo.endCursor) : Option.none();
40
+
41
+ /** Returns the history after the first page, without repeating that page. */
42
+ const readMore = (
43
+ client: LinearClientService,
44
+ issueId: string,
45
+ first: HistoryPage,
46
+ ): Effect.Effect<HistoryPage["nodes"], ConnectorError> =>
47
+ Option.match(nextCursor(first), {
48
+ onNone: () => Effect.succeed([]),
49
+ onSome: (cursor) =>
50
+ Stream.paginate(cursor, (after) =>
51
+ client
52
+ .query(
53
+ Schema.Struct({ issue: Schema.Struct({ history: HistoryPageSchema }) }),
54
+ `query IssueHistory($id: String!, $after: String) {
55
+ issue(id: $id) { history(first: 250, after: $after) { nodes { ${historyFields} } pageInfo { hasNextPage endCursor } } }
56
+ }`,
57
+ { id: issueId, after },
58
+ )
59
+ .pipe(
60
+ Effect.map(({ issue }) => [issue.history.nodes, nextCursor(issue.history)] as const),
61
+ ),
62
+ ).pipe(Stream.runCollect),
63
+ });
64
+
65
+ /** Linear has no top-level history query. */
66
+ const historySpec = (client: LinearClientService) =>
67
+ listSpec({
68
+ connection: "issues",
69
+ fields: `id updated_at: updatedAt history(first: 50) { nodes { ${historyFields} } pageInfo { hasNextPage endCursor } }`,
70
+ item: IssueWithHistorySchema,
71
+ // Each issue reads up to 50 history entries, so pages stay under the cost limit.
72
+ first: 25,
73
+ updatedAt: (issue) => issue.updated_at,
74
+ rows: (issues): Effect.Effect<ReadonlyArray<IssueHistory>, ConnectorError> =>
75
+ Effect.forEach(issues, (issue) =>
76
+ readMore(client, issue.id, issue.history).pipe(
77
+ Effect.map((more) =>
78
+ [...issue.history.nodes, ...more].map((entry) => ({
79
+ ...entry,
80
+ issue_id: issue.id,
81
+ version: entry.updated_at,
82
+ })),
83
+ ),
84
+ ),
85
+ ).pipe(Effect.map((rows) => rows.flat())),
86
+ });
87
+
88
+ export const makeIssueHistory = (client: LinearClientService) => {
89
+ const spec = historySpec(client);
90
+ return Resource.entity({
91
+ name: "issue_history",
92
+ rowSchema: IssueHistorySchema,
93
+ version: "version",
94
+
95
+ check: checkList(client, spec),
96
+ backfill: backfillList(client, spec),
97
+ changes: changesList(client, spec),
98
+ webhook: {
99
+ schema: Schema.Struct({ data: Schema.Struct({ id: Schema.String }) }),
100
+ handler: ({ payload }) =>
101
+ client
102
+ .find(
103
+ Schema.Struct({ issue: IssueWithHistorySchema }),
104
+ `query IssueHistory($id: String!) {
105
+ issue(id: $id) { ${spec.fields} }
106
+ }`,
107
+ { id: payload.data.id },
108
+ )
109
+ .pipe(
110
+ Effect.flatMap(
111
+ Option.match({
112
+ onNone: () => Effect.succeed([]),
113
+ onSome: ({ issue }) => spec.rows([issue]),
114
+ }),
115
+ ),
116
+ ),
117
+ },
118
+ });
119
+ };
@@ -0,0 +1,131 @@
1
+ import { Resource } from "@useairfoil/connector-kit";
2
+
3
+ import type { LinearClientService } from "../client/client";
4
+
5
+ import {
6
+ AttachmentObjectSchema,
7
+ AttachmentSchema,
8
+ IssueLabelObjectSchema,
9
+ IssueLabelSchema,
10
+ IssueObjectSchema,
11
+ IssueRelationObjectSchema,
12
+ IssueRelationSchema,
13
+ IssueSchema,
14
+ } from "../schemas/issues";
15
+ import {
16
+ backfillList,
17
+ changesList,
18
+ checkList,
19
+ listSources,
20
+ listSpec,
21
+ rowsOf,
22
+ webhookList,
23
+ } from "./pages";
24
+
25
+ const issueFields = `
26
+ id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
27
+ identifier number title description url branch_name: branchName
28
+ priority priority_label: priorityLabel estimate due_date: dueDate
29
+ creator_id: creator { id } assignee_id: assignee { id } delegate_id: delegate { id }
30
+ snoozed_by_id: snoozedBy { id } state_id: state { id } team_id: team { id }
31
+ project_id: project { id } project_milestone_id: projectMilestone { id }
32
+ cycle_id: cycle { id } parent_id: parent { id } source_comment_id: sourceComment { id }
33
+ external_user_creator_id: externalUserCreator { id }
34
+ label_ids: labelIds previous_identifiers: previousIdentifiers
35
+ started_at: startedAt completed_at: completedAt canceled_at: canceledAt
36
+ started_triage_at: startedTriageAt triaged_at: triagedAt auto_closed_at: autoClosedAt
37
+ auto_archived_at: autoArchivedAt added_to_cycle_at: addedToCycleAt
38
+ added_to_project_at: addedToProjectAt added_to_team_at: addedToTeamAt
39
+ snoozed_until_at: snoozedUntilAt sla_type: slaType sla_started_at: slaStartedAt
40
+ sla_breaches_at: slaBreachesAt customer_ticket_count: customerTicketCount
41
+ integration_source_type: integrationSourceType trashed`;
42
+
43
+ const issueSpec = listSpec({
44
+ connection: "issues",
45
+ single: "issue",
46
+ fields: issueFields,
47
+ item: IssueObjectSchema,
48
+ updatedAt: (issue) => issue.updated_at,
49
+ rows: rowsOf((issue) => ({ ...issue, version: issue.updated_at, _deleted: false })),
50
+ });
51
+
52
+ export const makeIssues = (client: LinearClientService) =>
53
+ Resource.entity({
54
+ name: "issues",
55
+ rowSchema: IssueSchema,
56
+ version: "version",
57
+
58
+ check: checkList(client, issueSpec),
59
+ backfill: backfillList(client, issueSpec),
60
+ changes: changesList(client, issueSpec),
61
+ webhook: webhookList(client, issueSpec),
62
+ });
63
+
64
+ const issueRelationSpec = listSpec({
65
+ connection: "issueRelations",
66
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
67
+ issue_id: issue { id } related_issue_id: relatedIssue { id } type`,
68
+ item: IssueRelationObjectSchema,
69
+ updatedAt: (relation) => relation.updated_at,
70
+ rows: rowsOf((relation) => ({ ...relation, version: relation.updated_at })),
71
+ });
72
+
73
+ export const makeIssueRelations = (client: LinearClientService) =>
74
+ Resource.entity({
75
+ name: "issue_relations",
76
+ rowSchema: IssueRelationSchema,
77
+ version: "version",
78
+
79
+ check: checkList(client, issueRelationSpec),
80
+ backfill: backfillList(client, issueRelationSpec),
81
+ changes: changesList(client, issueRelationSpec),
82
+ });
83
+
84
+ const attachmentSpec = listSpec({
85
+ connection: "attachments",
86
+ single: "attachment",
87
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
88
+ issue_id: issue { id } creator_id: creator { id }
89
+ external_user_creator_id: externalUserCreator { id }
90
+ title subtitle url source_type: sourceType`,
91
+ item: AttachmentObjectSchema,
92
+ updatedAt: (attachment) => attachment.updated_at,
93
+ rows: rowsOf((attachment) => ({
94
+ ...attachment,
95
+ version: attachment.updated_at,
96
+ _deleted: false,
97
+ })),
98
+ });
99
+
100
+ export const makeAttachments = (client: LinearClientService) =>
101
+ Resource.entity({
102
+ name: "attachments",
103
+ rowSchema: AttachmentSchema,
104
+ version: "version",
105
+
106
+ check: checkList(client, attachmentSpec),
107
+ backfill: backfillList(client, attachmentSpec),
108
+ changes: changesList(client, attachmentSpec),
109
+ webhook: webhookList(client, attachmentSpec),
110
+ });
111
+
112
+ const issueLabelSpec = listSpec({
113
+ connection: "issueLabels",
114
+ single: "issueLabel",
115
+ fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
116
+ name description color is_group: isGroup parent_id: parent { id } team_id: team { id }
117
+ creator_id: creator { id } retired_at: retiredAt`,
118
+ item: IssueLabelObjectSchema,
119
+ updatedAt: (label) => label.updated_at,
120
+ rows: rowsOf((label) => ({ ...label, version: label.updated_at, _deleted: false })),
121
+ });
122
+
123
+ export const makeIssueLabels = (client: LinearClientService) =>
124
+ Resource.entity({
125
+ name: "issue_labels",
126
+ rowSchema: IssueLabelSchema,
127
+ version: "version",
128
+
129
+ ...listSources(client, issueLabelSpec),
130
+ webhook: webhookList(client, issueLabelSpec),
131
+ });
@@ -0,0 +1,264 @@
1
+ import { ConnectorError, Cursor, Fetch } from "@useairfoil/connector-kit";
2
+ import {
3
+ Array as Arr,
4
+ DateTime,
5
+ Duration,
6
+ Effect,
7
+ Option,
8
+ Schema,
9
+ SchemaTransformation,
10
+ Stream,
11
+ String as Str,
12
+ } from "effect";
13
+
14
+ import type { LinearClientService } from "../client/client";
15
+
16
+ const changesInterval = Duration.minutes(5);
17
+ const hourly = Duration.hours(1);
18
+ // A run stops after this many pages and the next one starts right away.
19
+ const maxPagesPerRun = 20;
20
+ // Updates can show up in a list late, so each changes read starts a bit early.
21
+ const changesOverlap = Duration.minutes(1);
22
+
23
+ type ListSpec<Item, Row> = {
24
+ /** List query, such as `issues`. */
25
+ readonly connection: string;
26
+ /** Single-item query for webhooks, such as `issue`. */
27
+ readonly single?: string;
28
+ /** GraphQL fields, aliased to match the schema. */
29
+ readonly fields: string;
30
+ readonly item: Schema.Decoder<Item>;
31
+ /** Page size. Lower it for queries with nested lists. */
32
+ readonly first?: number;
33
+ readonly updatedAt: (item: Item) => Date;
34
+ /** Builds rows and may fetch more nested pages. */
35
+ readonly rows: (items: ReadonlyArray<Item>) => Effect.Effect<ReadonlyArray<Row>, ConnectorError>;
36
+ };
37
+
38
+ /** Keeps item types inferred in the callbacks. */
39
+ export const listSpec = <Item, Row>(spec: ListSpec<Item, Row>) => spec;
40
+
41
+ export const rowsOf =
42
+ <Item, Row>(toRow: (item: Item) => Row) =>
43
+ (items: ReadonlyArray<Item>) =>
44
+ Effect.succeed(items.map(toRow));
45
+
46
+ type Page<Item> = {
47
+ readonly items: ReadonlyArray<Item>;
48
+ readonly next: Option.Option<string>;
49
+ };
50
+
51
+ const PageInfoSchema = Schema.Struct({
52
+ hasNextPage: Schema.Boolean,
53
+ endCursor: Schema.NullOr(Schema.String),
54
+ });
55
+
56
+ const readPage = <Item, Row>(
57
+ client: LinearClientService,
58
+ spec: ListSpec<Item, Row>,
59
+ options: {
60
+ readonly orderBy: "createdAt" | "updatedAt";
61
+ readonly after: Option.Option<string>;
62
+ readonly first?: number;
63
+ },
64
+ ): Effect.Effect<Page<Item>, ConnectorError> =>
65
+ client
66
+ .query(
67
+ Schema.Struct({
68
+ page: Schema.Struct({ nodes: Schema.Array(spec.item), pageInfo: PageInfoSchema }),
69
+ }).pipe(Schema.encodeKeys({ page: spec.connection })),
70
+ `query ${Str.capitalize(spec.connection)}($first: Int!, $after: String, $orderBy: PaginationOrderBy) {
71
+ ${spec.connection}(first: $first, after: $after, orderBy: $orderBy, includeArchived: true) {
72
+ nodes { ${spec.fields} }
73
+ pageInfo { hasNextPage endCursor }
74
+ }
75
+ }`,
76
+ {
77
+ first: options.first ?? spec.first ?? 250,
78
+ after: Option.getOrNull(options.after),
79
+ orderBy: options.orderBy,
80
+ },
81
+ )
82
+ .pipe(
83
+ Effect.map(({ page }) => ({
84
+ items: page.nodes,
85
+ next: page.pageInfo.hasNextPage
86
+ ? Option.fromNullishOr(page.pageInfo.endCursor)
87
+ : Option.none(),
88
+ })),
89
+ );
90
+
91
+ const listAll = <Item, Row>(
92
+ client: LinearClientService,
93
+ spec: ListSpec<Item, Row>,
94
+ ): Effect.Effect<ReadonlyArray<Item>, ConnectorError> =>
95
+ Stream.paginate(Option.none<string>(), (after) =>
96
+ readPage(client, spec, { orderBy: "createdAt", after }).pipe(
97
+ Effect.map(({ items, next }) => [items, Option.map(next, Option.some)] as const),
98
+ ),
99
+ ).pipe(Stream.runCollect);
100
+
101
+ export const checkList = <Item, Row>(client: LinearClientService, spec: ListSpec<Item, Row>) =>
102
+ readPage(client, spec, { orderBy: "createdAt", after: Option.none(), first: 1 }).pipe(
103
+ Effect.flatMap(({ items }) => spec.rows(items)),
104
+ Effect.asVoid,
105
+ );
106
+
107
+ /** Small lists are read in full every hour. */
108
+ export const listSources = <Item, Row extends object>(
109
+ client: LinearClientService,
110
+ spec: ListSpec<Item, Row>,
111
+ ) => {
112
+ const read = listAll(client, spec).pipe(Effect.flatMap(spec.rows));
113
+ return {
114
+ check: checkList(client, spec),
115
+ backfill: Fetch.page({
116
+ pageCursor: Cursor.string(),
117
+ cutoff: Cursor.isoDateTime(),
118
+ fetch: () => read.pipe(Effect.map((rows) => ({ rows, hasMore: false }))),
119
+ }),
120
+ changes: Fetch.changes({
121
+ cursor: Cursor.string(),
122
+ interval: hourly,
123
+ fetch: () =>
124
+ Effect.all([read, DateTime.now]).pipe(
125
+ Effect.map(([rows, now]) => ({ rows, cursor: DateTime.formatIso(now) })),
126
+ ),
127
+ }),
128
+ };
129
+ };
130
+
131
+ /** Changes pick up items created during backfill. */
132
+ export const backfillList = <Item, Row extends object>(
133
+ client: LinearClientService,
134
+ spec: ListSpec<Item, Row>,
135
+ ) =>
136
+ Fetch.page({
137
+ pageCursor: Cursor.string(),
138
+ cutoff: Cursor.isoDateTime(),
139
+ fetch: ({ pageCursor }) =>
140
+ Effect.gen(function* () {
141
+ const page = yield* readPage(client, spec, {
142
+ orderBy: "createdAt",
143
+ after: Option.fromNullishOr(typeof pageCursor === "string" ? pageCursor : undefined),
144
+ });
145
+ return {
146
+ rows: yield* spec.rows(page.items),
147
+ hasMore: Option.isSome(page.next),
148
+ ...Option.match(page.next, {
149
+ onNone: () => ({}),
150
+ onSome: (nextPageCursor) => ({ nextPageCursor }),
151
+ }),
152
+ };
153
+ }),
154
+ });
155
+
156
+ /**
157
+ * `since` is when the last run started. `pending` is set while a run is split:
158
+ * `until` is the start of that run, and `after` the page to go on from.
159
+ */
160
+ const ChangesCursorSchema = Schema.Struct({
161
+ since: Schema.DateTimeUtcFromString,
162
+ pending: Schema.optional(
163
+ Schema.Struct({ until: Schema.DateTimeUtcFromString, after: Schema.String }),
164
+ ),
165
+ });
166
+
167
+ type ChangesCursor = Schema.Schema.Type<typeof ChangesCursorSchema>;
168
+
169
+ const ChangesCursorCodec = Schema.Union([
170
+ Schema.fromJsonString(ChangesCursorSchema),
171
+ // The first cursor is the backfill cutoff, a plain ISO time.
172
+ Schema.DateTimeUtcFromString.pipe(
173
+ Schema.decodeTo(
174
+ Schema.toType(ChangesCursorSchema),
175
+ SchemaTransformation.transform({
176
+ decode: (since): ChangesCursor => ({ since }),
177
+ encode: (cursor) => cursor.since,
178
+ }),
179
+ ),
180
+ ),
181
+ ]);
182
+
183
+ const cursorError = (cause: Schema.SchemaError) =>
184
+ new ConnectorError({ message: "Invalid Linear changes cursor", cause });
185
+
186
+ /**
187
+ * Newest first: an edit during paging is read again on the next run.
188
+ */
189
+ export const changesList = <Item, Row extends object>(
190
+ client: LinearClientService,
191
+ spec: ListSpec<Item, Row>,
192
+ ) =>
193
+ Fetch.changes({
194
+ cursor: Cursor.string(),
195
+ interval: changesInterval,
196
+ fetch: ({ cursor }) =>
197
+ Effect.gen(function* () {
198
+ const state = yield* Schema.decodeUnknownEffect(ChangesCursorCodec)(String(cursor)).pipe(
199
+ Effect.mapError(cursorError),
200
+ );
201
+ const until = state.pending?.until ?? (yield* DateTime.now);
202
+ const from = DateTime.toDateUtc(DateTime.subtractDuration(state.since, changesOverlap));
203
+ const isRecent = (item: Item) => spec.updatedAt(item).getTime() >= from.getTime();
204
+
205
+ const pages = yield* Stream.paginate(Option.fromNullishOr(state.pending?.after), (after) =>
206
+ Effect.gen(function* () {
207
+ const page = yield* readPage(client, spec, { orderBy: "updatedAt", after });
208
+ const recent = page.items.filter(isRecent);
209
+ const next = recent.length === page.items.length ? page.next : Option.none();
210
+ const rows = yield* spec.rows(recent);
211
+ return [[{ rows, next }], Option.map(next, Option.some)] as const;
212
+ }),
213
+ ).pipe(Stream.take(maxPagesPerRun), Stream.runCollect);
214
+
215
+ const rows = pages.flatMap((page) => page.rows);
216
+ const after = Option.flatMap(Arr.last(pages), (page) => page.next);
217
+ const next: ChangesCursor =
218
+ pages.length === maxPagesPerRun && Option.isSome(after)
219
+ ? { since: state.since, pending: { until, after: after.value } }
220
+ : { since: until };
221
+ return {
222
+ rows,
223
+ cursor: yield* Schema.encodeEffect(Schema.fromJsonString(ChangesCursorSchema))(next).pipe(
224
+ Effect.mapError(cursorError),
225
+ ),
226
+ hasMore: next.pending !== undefined,
227
+ };
228
+ }),
229
+ });
230
+
231
+ const WebhookPayloadSchema = Schema.Struct({
232
+ data: Schema.Struct({ id: Schema.String }),
233
+ webhookTimestamp: Schema.Number,
234
+ });
235
+
236
+ type WebhookPayload = Schema.Schema.Type<typeof WebhookPayloadSchema>;
237
+
238
+ /**
239
+ * Archive and trash also send `remove`, without changing `updatedAt`.
240
+ * Read the item again; delete only if it's gone. Use the webhook time as the version.
241
+ */
242
+ export const webhookList = <Item, Row extends object>(
243
+ client: LinearClientService,
244
+ spec: ListSpec<Item, Row>,
245
+ ) => ({
246
+ schema: WebhookPayloadSchema,
247
+ handler: ({ payload }: { readonly payload: WebhookPayload }) =>
248
+ Effect.gen(function* () {
249
+ const version = new Date(payload.webhookTimestamp);
250
+ const single = spec.single ?? spec.connection;
251
+ const found = yield* client.find(
252
+ Schema.Struct({ item: spec.item }).pipe(Schema.encodeKeys({ item: single })),
253
+ `query ${Str.capitalize(single)}($id: String!) {
254
+ ${single}(id: $id) { ${spec.fields} }
255
+ }`,
256
+ { id: payload.data.id },
257
+ );
258
+ return yield* Option.match(found, {
259
+ onNone: () => Effect.succeed([{ id: payload.data.id, version, _deleted: true as const }]),
260
+ onSome: ({ item }) =>
261
+ spec.rows([item]).pipe(Effect.map((rows) => rows.map((row) => ({ ...row, version })))),
262
+ });
263
+ }),
264
+ });