convex-linear 0.0.1
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.
- package/LICENSE +201 -0
- package/README.md +438 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +163 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +316 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +34 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +170 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +133 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +4 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/lib.d.ts +146 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +258 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +75 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +51 -0
- package/dist/component/schema.js.map +1 -0
- package/package.json +105 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +460 -0
- package/src/client/setup.test.ts +26 -0
- package/src/component/_generated/api.ts +50 -0
- package/src/component/_generated/component.ts +224 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +169 -0
- package/src/component/convex.config.ts +5 -0
- package/src/component/lib.test.ts +191 -0
- package/src/component/lib.ts +280 -0
- package/src/component/schema.ts +53 -0
- package/src/component/setup.test.ts +11 -0
- package/src/test.ts +18 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query } from "./_generated/server.js";
|
|
3
|
+
|
|
4
|
+
const issueValidator = v.object({
|
|
5
|
+
_id: v.id("issues"),
|
|
6
|
+
_creationTime: v.number(),
|
|
7
|
+
issueId: v.string(),
|
|
8
|
+
identifier: v.string(),
|
|
9
|
+
teamId: v.string(),
|
|
10
|
+
title: v.string(),
|
|
11
|
+
description: v.optional(v.string()),
|
|
12
|
+
state: v.string(),
|
|
13
|
+
priority: v.optional(v.number()),
|
|
14
|
+
assigneeId: v.optional(v.string()),
|
|
15
|
+
assigneeName: v.optional(v.string()),
|
|
16
|
+
labels: v.optional(v.array(v.string())),
|
|
17
|
+
url: v.string(),
|
|
18
|
+
archivedAt: v.optional(v.number()),
|
|
19
|
+
trashed: v.optional(v.boolean()),
|
|
20
|
+
createdAt: v.number(),
|
|
21
|
+
updatedAt: v.number(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const commentValidator = v.object({
|
|
25
|
+
_id: v.id("comments"),
|
|
26
|
+
_creationTime: v.number(),
|
|
27
|
+
commentId: v.string(),
|
|
28
|
+
issueId: v.string(),
|
|
29
|
+
body: v.string(),
|
|
30
|
+
userId: v.optional(v.string()),
|
|
31
|
+
userName: v.optional(v.string()),
|
|
32
|
+
createdAt: v.number(),
|
|
33
|
+
updatedAt: v.number(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ─── Queries ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export const getIssue = query({
|
|
39
|
+
args: { issueId: v.string() },
|
|
40
|
+
returns: v.union(v.null(), issueValidator),
|
|
41
|
+
handler: async (ctx, args) => {
|
|
42
|
+
return await ctx.db
|
|
43
|
+
.query("issues")
|
|
44
|
+
.withIndex("by_issueId", (q) => q.eq("issueId", args.issueId))
|
|
45
|
+
.first();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const listIssuesByTeam = query({
|
|
50
|
+
args: { teamId: v.string(), limit: v.optional(v.number()) },
|
|
51
|
+
returns: v.array(issueValidator),
|
|
52
|
+
handler: async (ctx, args) => {
|
|
53
|
+
return await ctx.db
|
|
54
|
+
.query("issues")
|
|
55
|
+
.withIndex("by_teamId", (q) => q.eq("teamId", args.teamId))
|
|
56
|
+
.order("desc")
|
|
57
|
+
.take(args.limit ?? 50);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const listCommentsByIssue = query({
|
|
62
|
+
args: { issueId: v.string(), limit: v.optional(v.number()) },
|
|
63
|
+
returns: v.array(commentValidator),
|
|
64
|
+
handler: async (ctx, args) => {
|
|
65
|
+
return await ctx.db
|
|
66
|
+
.query("comments")
|
|
67
|
+
.withIndex("by_issueId", (q) => q.eq("issueId", args.issueId))
|
|
68
|
+
.order("desc")
|
|
69
|
+
.take(args.limit ?? 50);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ─── Mutations ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export const recordIssue = mutation({
|
|
76
|
+
args: {
|
|
77
|
+
issueId: v.string(),
|
|
78
|
+
identifier: v.string(),
|
|
79
|
+
teamId: v.string(),
|
|
80
|
+
title: v.string(),
|
|
81
|
+
description: v.optional(v.string()),
|
|
82
|
+
state: v.string(),
|
|
83
|
+
priority: v.optional(v.number()),
|
|
84
|
+
assigneeId: v.optional(v.string()),
|
|
85
|
+
assigneeName: v.optional(v.string()),
|
|
86
|
+
labels: v.optional(v.array(v.string())),
|
|
87
|
+
url: v.string(),
|
|
88
|
+
archivedAt: v.optional(v.number()),
|
|
89
|
+
trashed: v.optional(v.boolean()),
|
|
90
|
+
},
|
|
91
|
+
returns: v.id("issues"),
|
|
92
|
+
handler: async (ctx, args) => {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const existing = await ctx.db
|
|
95
|
+
.query("issues")
|
|
96
|
+
.withIndex("by_issueId", (q) => q.eq("issueId", args.issueId))
|
|
97
|
+
.first();
|
|
98
|
+
|
|
99
|
+
if (existing) {
|
|
100
|
+
// recordIssue is always called with a full, fresh snapshot from
|
|
101
|
+
// Linear (see issueRecordFromFragment in client/index.ts) — so every
|
|
102
|
+
// optional field is listed explicitly here, even when its value is
|
|
103
|
+
// undefined. A bare `...args` spread silently keeps whatever was
|
|
104
|
+
// already stored for a field Linear no longer reports (an unassigned
|
|
105
|
+
// issue, a cleared label list, a restored-from-trash issue), because
|
|
106
|
+
// an omitted optional argument never appears as a key on `args` at
|
|
107
|
+
// all — so spreading it into patch() doesn't touch the old value.
|
|
108
|
+
// Naming the field here forces the key to exist, which is what makes
|
|
109
|
+
// Convex's patch() actually clear it.
|
|
110
|
+
await ctx.db.patch(existing._id, {
|
|
111
|
+
identifier: args.identifier,
|
|
112
|
+
teamId: args.teamId,
|
|
113
|
+
title: args.title,
|
|
114
|
+
description: args.description,
|
|
115
|
+
state: args.state,
|
|
116
|
+
priority: args.priority,
|
|
117
|
+
assigneeId: args.assigneeId,
|
|
118
|
+
assigneeName: args.assigneeName,
|
|
119
|
+
labels: args.labels,
|
|
120
|
+
url: args.url,
|
|
121
|
+
archivedAt: args.archivedAt,
|
|
122
|
+
trashed: args.trashed,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
});
|
|
125
|
+
return existing._id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return await ctx.db.insert("issues", { ...args, createdAt: now, updatedAt: now });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const recordComment = mutation({
|
|
133
|
+
args: {
|
|
134
|
+
commentId: v.string(),
|
|
135
|
+
issueId: v.string(),
|
|
136
|
+
body: v.string(),
|
|
137
|
+
userId: v.optional(v.string()),
|
|
138
|
+
userName: v.optional(v.string()),
|
|
139
|
+
},
|
|
140
|
+
returns: v.id("comments"),
|
|
141
|
+
handler: async (ctx, args) => {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const existing = await ctx.db
|
|
144
|
+
.query("comments")
|
|
145
|
+
.withIndex("by_commentId", (q) => q.eq("commentId", args.commentId))
|
|
146
|
+
.first();
|
|
147
|
+
|
|
148
|
+
if (existing) {
|
|
149
|
+
await ctx.db.patch(existing._id, { ...args, updatedAt: now });
|
|
150
|
+
return existing._id;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return await ctx.db.insert("comments", { ...args, createdAt: now, updatedAt: now });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const removeIssue = mutation({
|
|
158
|
+
args: { issueId: v.string() },
|
|
159
|
+
returns: v.null(),
|
|
160
|
+
handler: async (ctx, args) => {
|
|
161
|
+
const existing = await ctx.db
|
|
162
|
+
.query("issues")
|
|
163
|
+
.withIndex("by_issueId", (q) => q.eq("issueId", args.issueId))
|
|
164
|
+
.first();
|
|
165
|
+
if (existing) {
|
|
166
|
+
await ctx.db.delete(existing._id);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Marks an issue archived (or restores it, when archivedAt is null) without
|
|
173
|
+
// deleting the local row. Linear's own archive is a soft-hide (restorable via
|
|
174
|
+
// issueUnarchive), so the local mirror should reflect that instead of
|
|
175
|
+
// disappearing the issue entirely — removeIssue() stays reserved for Linear's
|
|
176
|
+
// actual delete/remove events.
|
|
177
|
+
export const setIssueArchived = mutation({
|
|
178
|
+
args: { issueId: v.string(), archivedAt: v.union(v.number(), v.null()) },
|
|
179
|
+
returns: v.null(),
|
|
180
|
+
handler: async (ctx, args) => {
|
|
181
|
+
const existing = await ctx.db
|
|
182
|
+
.query("issues")
|
|
183
|
+
.withIndex("by_issueId", (q) => q.eq("issueId", args.issueId))
|
|
184
|
+
.first();
|
|
185
|
+
if (!existing) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
await ctx.db.patch(existing._id, {
|
|
189
|
+
archivedAt: args.archivedAt ?? undefined,
|
|
190
|
+
updatedAt: Date.now(),
|
|
191
|
+
});
|
|
192
|
+
return null;
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ─── Dashboard queries ──────────────────────────────────────────────────────
|
|
197
|
+
// These do full, un-indexed scans across every issue/comment/event the
|
|
198
|
+
// component has ever recorded, on purpose — they power a demo's stats bar
|
|
199
|
+
// and activity history, not high-volume production use.
|
|
200
|
+
|
|
201
|
+
export const getStats = query({
|
|
202
|
+
args: {},
|
|
203
|
+
returns: v.object({
|
|
204
|
+
issueCount: v.number(),
|
|
205
|
+
archivedCount: v.number(),
|
|
206
|
+
commentCount: v.number(),
|
|
207
|
+
webhookEventCount: v.number(),
|
|
208
|
+
}),
|
|
209
|
+
handler: async (ctx) => {
|
|
210
|
+
const [issues, comments, webhookEvents] = await Promise.all([
|
|
211
|
+
ctx.db.query("issues").collect(),
|
|
212
|
+
ctx.db.query("comments").collect(),
|
|
213
|
+
ctx.db.query("webhookEvents").collect(),
|
|
214
|
+
]);
|
|
215
|
+
return {
|
|
216
|
+
issueCount: issues.length,
|
|
217
|
+
archivedCount: issues.filter((issue) => issue.archivedAt !== undefined).length,
|
|
218
|
+
commentCount: comments.length,
|
|
219
|
+
webhookEventCount: webhookEvents.length,
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
export const listRecentIssues = query({
|
|
225
|
+
args: { limit: v.optional(v.number()) },
|
|
226
|
+
returns: v.array(issueValidator),
|
|
227
|
+
handler: async (ctx, args) => {
|
|
228
|
+
const issues = await ctx.db.query("issues").collect();
|
|
229
|
+
return issues.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, args.limit ?? 20);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const listRecentComments = query({
|
|
234
|
+
args: { limit: v.optional(v.number()) },
|
|
235
|
+
returns: v.array(commentValidator),
|
|
236
|
+
handler: async (ctx, args) => {
|
|
237
|
+
const comments = await ctx.db.query("comments").collect();
|
|
238
|
+
return comments.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, args.limit ?? 20);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
export const listRecentWebhookEvents = query({
|
|
243
|
+
args: { limit: v.optional(v.number()) },
|
|
244
|
+
returns: v.array(
|
|
245
|
+
v.object({
|
|
246
|
+
_id: v.id("webhookEvents"),
|
|
247
|
+
_creationTime: v.number(),
|
|
248
|
+
eventId: v.string(),
|
|
249
|
+
eventType: v.string(),
|
|
250
|
+
action: v.optional(v.string()),
|
|
251
|
+
payload: v.string(),
|
|
252
|
+
receivedAt: v.number(),
|
|
253
|
+
}),
|
|
254
|
+
),
|
|
255
|
+
handler: async (ctx, args) => {
|
|
256
|
+
const events = await ctx.db.query("webhookEvents").collect();
|
|
257
|
+
return events.sort((a, b) => b.receivedAt - a.receivedAt).slice(0, args.limit ?? 20);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
export const checkAndRecordEvent = mutation({
|
|
262
|
+
args: {
|
|
263
|
+
eventId: v.string(),
|
|
264
|
+
eventType: v.string(),
|
|
265
|
+
action: v.optional(v.string()),
|
|
266
|
+
payload: v.string(),
|
|
267
|
+
},
|
|
268
|
+
returns: v.object({ alreadyProcessed: v.boolean() }),
|
|
269
|
+
handler: async (ctx, args) => {
|
|
270
|
+
const existing = await ctx.db
|
|
271
|
+
.query("webhookEvents")
|
|
272
|
+
.withIndex("by_eventId", (q) => q.eq("eventId", args.eventId))
|
|
273
|
+
.first();
|
|
274
|
+
if (existing) {
|
|
275
|
+
return { alreadyProcessed: true };
|
|
276
|
+
}
|
|
277
|
+
await ctx.db.insert("webhookEvents", { ...args, receivedAt: Date.now() });
|
|
278
|
+
return { alreadyProcessed: false };
|
|
279
|
+
},
|
|
280
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
issues: defineTable({
|
|
6
|
+
issueId: v.string(), // Linear's UUID
|
|
7
|
+
identifier: v.string(), // e.g. "ENG-123"
|
|
8
|
+
teamId: v.string(),
|
|
9
|
+
title: v.string(),
|
|
10
|
+
description: v.optional(v.string()),
|
|
11
|
+
state: v.string(), // Linear workflow state name (team-defined, not a fixed enum)
|
|
12
|
+
priority: v.optional(v.number()), // Linear's own scale: 0 none, 1 urgent, 2 high, 3 normal, 4 low
|
|
13
|
+
assigneeId: v.optional(v.string()),
|
|
14
|
+
assigneeName: v.optional(v.string()),
|
|
15
|
+
labels: v.optional(v.array(v.string())),
|
|
16
|
+
url: v.string(),
|
|
17
|
+
// Set from Issue.archivedAt when Linear archives the issue (via the webhook or
|
|
18
|
+
// archiveIssue()) — archived issues are kept, not deleted, so a stale local
|
|
19
|
+
// mirror never quietly diverges from what's actually in Linear.
|
|
20
|
+
archivedAt: v.optional(v.number()),
|
|
21
|
+
// Linear's own "moved to trash" flag — distinct from archivedAt. A
|
|
22
|
+
// trashed issue is still recoverable for 30 days (and its own webhook
|
|
23
|
+
// delivery arrives as an "update", not "remove" — see client/index.ts).
|
|
24
|
+
trashed: v.optional(v.boolean()),
|
|
25
|
+
createdAt: v.number(),
|
|
26
|
+
updatedAt: v.number(),
|
|
27
|
+
})
|
|
28
|
+
.index("by_issueId", ["issueId"])
|
|
29
|
+
.index("by_teamId", ["teamId"]),
|
|
30
|
+
|
|
31
|
+
comments: defineTable({
|
|
32
|
+
commentId: v.string(), // Linear's UUID
|
|
33
|
+
issueId: v.string(),
|
|
34
|
+
body: v.string(),
|
|
35
|
+
userId: v.optional(v.string()),
|
|
36
|
+
userName: v.optional(v.string()),
|
|
37
|
+
createdAt: v.number(),
|
|
38
|
+
updatedAt: v.number(),
|
|
39
|
+
})
|
|
40
|
+
.index("by_commentId", ["commentId"])
|
|
41
|
+
.index("by_issueId", ["issueId"]),
|
|
42
|
+
|
|
43
|
+
webhookEvents: defineTable({
|
|
44
|
+
eventId: v.string(),
|
|
45
|
+
eventType: v.string(), // Linear-Event: "Issue" | "Comment" | ...
|
|
46
|
+
// payload.action: "create" | "update" | "remove" | "restore" — NOTE: for
|
|
47
|
+
// Issue events this does not reliably indicate archived vs trashed vs
|
|
48
|
+
// deleted (see client/index.ts); it's recorded here only for auditing.
|
|
49
|
+
action: v.optional(v.string()),
|
|
50
|
+
payload: v.string(),
|
|
51
|
+
receivedAt: v.number(),
|
|
52
|
+
}).index("by_eventId", ["eventId"]),
|
|
53
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { test } from "vitest";
|
|
3
|
+
import schema from "./schema.js";
|
|
4
|
+
import { convexTest } from "convex-test";
|
|
5
|
+
export const modules = import.meta.glob("./**/*.*s");
|
|
6
|
+
|
|
7
|
+
export function initConvexTest() {
|
|
8
|
+
const t = convexTest(schema, modules);
|
|
9
|
+
return t;
|
|
10
|
+
}
|
|
11
|
+
test("setup", () => {});
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import type { TestConvex } from "convex-test";
|
|
3
|
+
import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
4
|
+
import schema from "./component/schema.js";
|
|
5
|
+
const modules = import.meta.glob("./component/**/*.ts");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the component with the test convex instance.
|
|
9
|
+
* @param t - The test convex instance, e.g. from calling `convexTest`.
|
|
10
|
+
* @param name - The name of the component, as registered in convex.config.ts.
|
|
11
|
+
*/
|
|
12
|
+
export function register(
|
|
13
|
+
t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
|
|
14
|
+
name: string = "convexLinear",
|
|
15
|
+
) {
|
|
16
|
+
t.registerComponent(name, schema, modules);
|
|
17
|
+
}
|
|
18
|
+
export default { register, schema, modules };
|