@jonsoc/convex 1.1.49 → 1.1.51
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/convex/http.ts +205 -1
- package/convex/schema.ts +37 -1
- package/convex/share.ts +220 -0
- package/package.json +2 -2
package/convex/http.ts
CHANGED
|
@@ -1,9 +1,213 @@
|
|
|
1
1
|
import { httpRouter } from "convex/server"
|
|
2
|
+
import type { HttpRouter } from "convex/server"
|
|
2
3
|
|
|
3
4
|
import { authComponent, createAuth } from "./auth"
|
|
4
5
|
|
|
5
|
-
const http = httpRouter()
|
|
6
|
+
const http: HttpRouter = httpRouter()
|
|
6
7
|
|
|
7
8
|
authComponent.registerRoutes(http, createAuth, { cors: true })
|
|
8
9
|
|
|
10
|
+
// Share API endpoints - POST /api/share
|
|
11
|
+
http.route({
|
|
12
|
+
path: "/api/share",
|
|
13
|
+
method: "POST",
|
|
14
|
+
handler: async (ctx, request) => {
|
|
15
|
+
try {
|
|
16
|
+
const body = await request.json()
|
|
17
|
+
|
|
18
|
+
// Import the share module dynamically
|
|
19
|
+
const { create } = await import("./share")
|
|
20
|
+
|
|
21
|
+
const result = await ctx.runMutation(create, {
|
|
22
|
+
sessionID: body.sessionID,
|
|
23
|
+
slug: body.sessionID, // Use sessionID as slug
|
|
24
|
+
secret: generateSecret(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return new Response(JSON.stringify(result), {
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"Access-Control-Allow-Origin": "*",
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
36
|
+
status: 500,
|
|
37
|
+
headers: {
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
"Access-Control-Allow-Origin": "*",
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Share sync endpoint - POST /api/share/:slug/sync
|
|
47
|
+
http.route({
|
|
48
|
+
path: "/api/share/:slug/sync",
|
|
49
|
+
method: "POST",
|
|
50
|
+
handler: async (ctx, request) => {
|
|
51
|
+
try {
|
|
52
|
+
const slug = request.params.slug
|
|
53
|
+
const body = await request.json()
|
|
54
|
+
|
|
55
|
+
const { sync } = await import("./share")
|
|
56
|
+
|
|
57
|
+
await ctx.runMutation(sync, {
|
|
58
|
+
slug,
|
|
59
|
+
secret: body.secret,
|
|
60
|
+
data: body.data,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"Access-Control-Allow-Origin": "*",
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
72
|
+
status: 400,
|
|
73
|
+
headers: {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Access-Control-Allow-Origin": "*",
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Share delete endpoint - DELETE /api/share/:slug
|
|
83
|
+
http.route({
|
|
84
|
+
path: "/api/share/:slug",
|
|
85
|
+
method: "DELETE",
|
|
86
|
+
handler: async (ctx, request) => {
|
|
87
|
+
try {
|
|
88
|
+
const slug = request.params.slug
|
|
89
|
+
const body = await request.json()
|
|
90
|
+
|
|
91
|
+
const { remove } = await import("./share")
|
|
92
|
+
|
|
93
|
+
await ctx.runMutation(remove, {
|
|
94
|
+
slug,
|
|
95
|
+
secret: body.secret,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
"Access-Control-Allow-Origin": "*",
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
107
|
+
status: 400,
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
"Access-Control-Allow-Origin": "*",
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Share get endpoint - GET /api/share/:slug
|
|
118
|
+
http.route({
|
|
119
|
+
path: "/api/share/:slug",
|
|
120
|
+
method: "GET",
|
|
121
|
+
handler: async (ctx, request) => {
|
|
122
|
+
try {
|
|
123
|
+
const slug = request.params.slug
|
|
124
|
+
|
|
125
|
+
const { getPublic } = await import("./share")
|
|
126
|
+
|
|
127
|
+
const result = await ctx.runQuery(getPublic, { slug })
|
|
128
|
+
|
|
129
|
+
if (!result) {
|
|
130
|
+
return new Response(JSON.stringify({ error: "Share not found" }), {
|
|
131
|
+
status: 404,
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
"Access-Control-Allow-Origin": "*",
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return new Response(JSON.stringify(result), {
|
|
140
|
+
status: 200,
|
|
141
|
+
headers: {
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
"Access-Control-Allow-Origin": "*",
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
148
|
+
status: 500,
|
|
149
|
+
headers: {
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
"Access-Control-Allow-Origin": "*",
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// CORS preflight for share endpoints
|
|
159
|
+
http.route({
|
|
160
|
+
path: "/api/share",
|
|
161
|
+
method: "OPTIONS",
|
|
162
|
+
handler: async () => {
|
|
163
|
+
return new Response(null, {
|
|
164
|
+
status: 204,
|
|
165
|
+
headers: {
|
|
166
|
+
"Access-Control-Allow-Origin": "*",
|
|
167
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
168
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
http.route({
|
|
175
|
+
path: "/api/share/:slug",
|
|
176
|
+
method: "OPTIONS",
|
|
177
|
+
handler: async () => {
|
|
178
|
+
return new Response(null, {
|
|
179
|
+
status: 204,
|
|
180
|
+
headers: {
|
|
181
|
+
"Access-Control-Allow-Origin": "*",
|
|
182
|
+
"Access-Control-Allow-Methods": "GET, DELETE, OPTIONS",
|
|
183
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
http.route({
|
|
190
|
+
path: "/api/share/:slug/sync",
|
|
191
|
+
method: "OPTIONS",
|
|
192
|
+
handler: async () => {
|
|
193
|
+
return new Response(null, {
|
|
194
|
+
status: 204,
|
|
195
|
+
headers: {
|
|
196
|
+
"Access-Control-Allow-Origin": "*",
|
|
197
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
198
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
function generateSecret(): string {
|
|
205
|
+
// Generate a random UUID-like string
|
|
206
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
207
|
+
const r = (Math.random() * 16) | 0
|
|
208
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8
|
|
209
|
+
return v.toString(16)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
9
213
|
export default http
|
package/convex/schema.ts
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
import { defineSchema, defineTable } from "convex/server"
|
|
2
2
|
import { v } from "convex/values"
|
|
3
3
|
|
|
4
|
-
export default defineSchema({
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
shares: defineTable({
|
|
6
|
+
// Unique share ID (ULID from CLI)
|
|
7
|
+
slug: v.string(),
|
|
8
|
+
// Secret for authentication (only share creator knows this)
|
|
9
|
+
secret: v.string(),
|
|
10
|
+
// Session metadata
|
|
11
|
+
sessionID: v.string(),
|
|
12
|
+
title: v.optional(v.string()),
|
|
13
|
+
model: v.optional(v.string()),
|
|
14
|
+
// Visibility
|
|
15
|
+
isPublic: v.boolean(),
|
|
16
|
+
// Timestamps
|
|
17
|
+
createdAt: v.number(),
|
|
18
|
+
updatedAt: v.number(),
|
|
19
|
+
// Soft delete
|
|
20
|
+
isDeleted: v.boolean(),
|
|
21
|
+
})
|
|
22
|
+
.index("by_slug", ["slug"])
|
|
23
|
+
.index("by_secret", ["secret"])
|
|
24
|
+
.index("by_session", ["sessionID"]),
|
|
25
|
+
|
|
26
|
+
// Share data stored as JSON blobs (messages, diffs, etc)
|
|
27
|
+
shareData: defineTable({
|
|
28
|
+
shareID: v.id("shares"),
|
|
29
|
+
// Type: "session" | "message" | "part" | "session_diff" | "model"
|
|
30
|
+
dataType: v.string(),
|
|
31
|
+
// Data ID (message ID, part ID, etc)
|
|
32
|
+
dataID: v.string(),
|
|
33
|
+
// JSON string of the actual data
|
|
34
|
+
payload: v.string(),
|
|
35
|
+
createdAt: v.number(),
|
|
36
|
+
})
|
|
37
|
+
.index("by_share", ["shareID"])
|
|
38
|
+
.index("by_share_data", ["shareID", "dataType"])
|
|
39
|
+
.index("by_data_id", ["dataID"]),
|
|
40
|
+
})
|
package/convex/share.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { v } from "convex/values"
|
|
2
|
+
import { mutation, query } from "./_generated/server"
|
|
3
|
+
|
|
4
|
+
export const create = mutation({
|
|
5
|
+
args: {
|
|
6
|
+
sessionID: v.string(),
|
|
7
|
+
slug: v.string(),
|
|
8
|
+
secret: v.string(),
|
|
9
|
+
},
|
|
10
|
+
handler: async (ctx, args) => {
|
|
11
|
+
const now = Date.now()
|
|
12
|
+
|
|
13
|
+
// Check if share already exists for this session
|
|
14
|
+
const existing = await ctx.db
|
|
15
|
+
.query("shares")
|
|
16
|
+
.withIndex("by_session", (q) => q.eq("sessionID", args.sessionID))
|
|
17
|
+
.first()
|
|
18
|
+
|
|
19
|
+
if (existing) {
|
|
20
|
+
// Return existing share
|
|
21
|
+
return {
|
|
22
|
+
id: existing.slug,
|
|
23
|
+
url: `/share/${existing.slug}`,
|
|
24
|
+
secret: existing.secret,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Create new share
|
|
29
|
+
const share = await ctx.db.insert("shares", {
|
|
30
|
+
slug: args.slug,
|
|
31
|
+
secret: args.secret,
|
|
32
|
+
sessionID: args.sessionID,
|
|
33
|
+
title: undefined,
|
|
34
|
+
model: undefined,
|
|
35
|
+
isPublic: true,
|
|
36
|
+
createdAt: now,
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
isDeleted: false,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: args.slug,
|
|
43
|
+
url: `/share/${args.slug}`,
|
|
44
|
+
secret: args.secret,
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const sync = mutation({
|
|
50
|
+
args: {
|
|
51
|
+
slug: v.string(),
|
|
52
|
+
secret: v.string(),
|
|
53
|
+
data: v.array(
|
|
54
|
+
v.object({
|
|
55
|
+
type: v.string(),
|
|
56
|
+
data: v.any(),
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
handler: async (ctx, args) => {
|
|
61
|
+
// Verify share exists and secret matches
|
|
62
|
+
const share = await ctx.db
|
|
63
|
+
.query("shares")
|
|
64
|
+
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
|
65
|
+
.first()
|
|
66
|
+
|
|
67
|
+
if (!share || share.secret !== args.secret) {
|
|
68
|
+
throw new Error("Share not found or invalid secret")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (share.isDeleted) {
|
|
72
|
+
throw new Error("Share has been deleted")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Process each data item
|
|
76
|
+
for (const item of args.data) {
|
|
77
|
+
const dataID = item.data?.id || crypto.randomUUID()
|
|
78
|
+
const payload = JSON.stringify(item.data)
|
|
79
|
+
|
|
80
|
+
// Check if data already exists
|
|
81
|
+
const existing = await ctx.db
|
|
82
|
+
.query("shareData")
|
|
83
|
+
.withIndex("by_data_id", (q) => q.eq("dataID", dataID))
|
|
84
|
+
.first()
|
|
85
|
+
|
|
86
|
+
if (existing) {
|
|
87
|
+
// Update existing
|
|
88
|
+
await ctx.db.patch(existing._id, {
|
|
89
|
+
payload,
|
|
90
|
+
createdAt: Date.now(),
|
|
91
|
+
})
|
|
92
|
+
} else {
|
|
93
|
+
// Create new
|
|
94
|
+
await ctx.db.insert("shareData", {
|
|
95
|
+
shareID: share._id,
|
|
96
|
+
dataType: item.type,
|
|
97
|
+
dataID,
|
|
98
|
+
payload,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Update share title/model if provided
|
|
104
|
+
if (item.type === "session") {
|
|
105
|
+
const sessionData = item.data
|
|
106
|
+
if (sessionData?.title) {
|
|
107
|
+
await ctx.db.patch(share._id, {
|
|
108
|
+
title: sessionData.title,
|
|
109
|
+
updatedAt: Date.now(),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Update share timestamp
|
|
116
|
+
await ctx.db.patch(share._id, {
|
|
117
|
+
updatedAt: Date.now(),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return { success: true }
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export const remove = mutation({
|
|
125
|
+
args: {
|
|
126
|
+
slug: v.string(),
|
|
127
|
+
secret: v.string(),
|
|
128
|
+
},
|
|
129
|
+
handler: async (ctx, args) => {
|
|
130
|
+
const share = await ctx.db
|
|
131
|
+
.query("shares")
|
|
132
|
+
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
|
133
|
+
.first()
|
|
134
|
+
|
|
135
|
+
if (!share || share.secret !== args.secret) {
|
|
136
|
+
throw new Error("Share not found or invalid secret")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Soft delete
|
|
140
|
+
await ctx.db.patch(share._id, {
|
|
141
|
+
isDeleted: true,
|
|
142
|
+
updatedAt: Date.now(),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return { success: true }
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
export const get = query({
|
|
150
|
+
args: {
|
|
151
|
+
slug: v.string(),
|
|
152
|
+
},
|
|
153
|
+
handler: async (ctx, args) => {
|
|
154
|
+
const share = await ctx.db
|
|
155
|
+
.query("shares")
|
|
156
|
+
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
|
157
|
+
.first()
|
|
158
|
+
|
|
159
|
+
if (!share || share.isDeleted || !share.isPublic) {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Get all share data
|
|
164
|
+
const data = await ctx.db
|
|
165
|
+
.query("shareData")
|
|
166
|
+
.withIndex("by_share", (q) => q.eq("shareID", share._id))
|
|
167
|
+
.collect()
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id: share.slug,
|
|
171
|
+
sessionID: share.sessionID,
|
|
172
|
+
title: share.title,
|
|
173
|
+
model: share.model,
|
|
174
|
+
createdAt: share.createdAt,
|
|
175
|
+
updatedAt: share.updatedAt,
|
|
176
|
+
data: data.map((d) => ({
|
|
177
|
+
type: d.dataType,
|
|
178
|
+
id: d.dataID,
|
|
179
|
+
data: JSON.parse(d.payload),
|
|
180
|
+
})),
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
export const getPublic = query({
|
|
186
|
+
args: {
|
|
187
|
+
slug: v.string(),
|
|
188
|
+
},
|
|
189
|
+
handler: async (ctx, args) => {
|
|
190
|
+
// Reuse get logic
|
|
191
|
+
const share = await ctx.db
|
|
192
|
+
.query("shares")
|
|
193
|
+
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
|
194
|
+
.first()
|
|
195
|
+
|
|
196
|
+
if (!share || share.isDeleted || !share.isPublic) {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Get all share data
|
|
201
|
+
const data = await ctx.db
|
|
202
|
+
.query("shareData")
|
|
203
|
+
.withIndex("by_share", (q) => q.eq("shareID", share._id))
|
|
204
|
+
.collect()
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: share.slug,
|
|
208
|
+
sessionID: share.sessionID,
|
|
209
|
+
title: share.title,
|
|
210
|
+
model: share.model,
|
|
211
|
+
createdAt: share.createdAt,
|
|
212
|
+
updatedAt: share.updatedAt,
|
|
213
|
+
data: data.map((d) => ({
|
|
214
|
+
type: d.dataType,
|
|
215
|
+
id: d.dataID,
|
|
216
|
+
data: JSON.parse(d.payload),
|
|
217
|
+
})),
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jonsoc/convex",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.51",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"zod": "4.1.8"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@jonsoc/config": "1.1.
|
|
26
|
+
"@jonsoc/config": "1.1.51",
|
|
27
27
|
"@types/node": "22.13.9",
|
|
28
28
|
"typescript": "5.8.2"
|
|
29
29
|
},
|