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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +438 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +163 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +316 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +34 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +170 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +133 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +80 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +4 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/lib.d.ts +146 -0
  32. package/dist/component/lib.d.ts.map +1 -0
  33. package/dist/component/lib.js +258 -0
  34. package/dist/component/lib.js.map +1 -0
  35. package/dist/component/schema.d.ts +75 -0
  36. package/dist/component/schema.d.ts.map +1 -0
  37. package/dist/component/schema.js +51 -0
  38. package/dist/component/schema.js.map +1 -0
  39. package/package.json +105 -0
  40. package/src/client/_generated/_ignore.ts +1 -0
  41. package/src/client/index.ts +460 -0
  42. package/src/client/setup.test.ts +26 -0
  43. package/src/component/_generated/api.ts +50 -0
  44. package/src/component/_generated/component.ts +224 -0
  45. package/src/component/_generated/dataModel.ts +60 -0
  46. package/src/component/_generated/server.ts +169 -0
  47. package/src/component/convex.config.ts +5 -0
  48. package/src/component/lib.test.ts +191 -0
  49. package/src/component/lib.ts +280 -0
  50. package/src/component/schema.ts +53 -0
  51. package/src/component/setup.test.ts +11 -0
  52. 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 };