@fatagnus/dink-convex 1.0.0

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 (49) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +282 -0
  3. package/convex/convex.config.ts +23 -0
  4. package/convex/crons.ts +37 -0
  5. package/convex/http.ts +421 -0
  6. package/convex/index.ts +20 -0
  7. package/convex/install.ts +172 -0
  8. package/convex/outbox.ts +198 -0
  9. package/convex/outboxProcessor.ts +240 -0
  10. package/convex/schema.ts +97 -0
  11. package/convex/sync.ts +327 -0
  12. package/dist/component.d.ts +34 -0
  13. package/dist/component.d.ts.map +1 -0
  14. package/dist/component.js +35 -0
  15. package/dist/component.js.map +1 -0
  16. package/dist/crdt.d.ts +82 -0
  17. package/dist/crdt.d.ts.map +1 -0
  18. package/dist/crdt.js +134 -0
  19. package/dist/crdt.js.map +1 -0
  20. package/dist/factories.d.ts +80 -0
  21. package/dist/factories.d.ts.map +1 -0
  22. package/dist/factories.js +159 -0
  23. package/dist/factories.js.map +1 -0
  24. package/dist/http.d.ts +238 -0
  25. package/dist/http.d.ts.map +1 -0
  26. package/dist/http.js +222 -0
  27. package/dist/http.js.map +1 -0
  28. package/dist/httpFactory.d.ts +39 -0
  29. package/dist/httpFactory.d.ts.map +1 -0
  30. package/dist/httpFactory.js +128 -0
  31. package/dist/httpFactory.js.map +1 -0
  32. package/dist/index.d.ts +68 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +73 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/schema.d.ts +217 -0
  37. package/dist/schema.d.ts.map +1 -0
  38. package/dist/schema.js +195 -0
  39. package/dist/schema.js.map +1 -0
  40. package/dist/syncFactories.d.ts +240 -0
  41. package/dist/syncFactories.d.ts.map +1 -0
  42. package/dist/syncFactories.js +623 -0
  43. package/dist/syncFactories.js.map +1 -0
  44. package/dist/triggers.d.ts +442 -0
  45. package/dist/triggers.d.ts.map +1 -0
  46. package/dist/triggers.js +705 -0
  47. package/dist/triggers.js.map +1 -0
  48. package/package.json +108 -0
  49. package/scripts/check-peer-deps.cjs +132 -0
package/convex/http.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * HTTP endpoints for dinkd communication.
3
+ *
4
+ * Provides the /api/dink/applyDelta and /api/dink/getSnapshot endpoints
5
+ * for bidirectional sync with edge devices via dinkd.
6
+ *
7
+ * @module convex/http
8
+ */
9
+
10
+ import { httpRouter } from "convex/server";
11
+ import { httpAction } from "./_generated/server";
12
+ import { api } from "./_generated/api";
13
+
14
+ const http = httpRouter();
15
+
16
+ /**
17
+ * POST /api/dink/applyDelta
18
+ *
19
+ * Receives CRDT deltas from edge devices and merges them into
20
+ * the target user table. Skips outbox queueing to prevent sync loops.
21
+ *
22
+ * Request:
23
+ * - Authorization: Bearer <appSyncKey>
24
+ * - Body: { collection: string, docId: string, bytes: number[] }
25
+ *
26
+ * Response:
27
+ * - 200: { success: true, seq: number }
28
+ * - 400: { success: false, error: string } - Invalid payload
29
+ * - 401: { success: false, error: string } - Unauthorized
30
+ * - 500: { success: false, error: string } - Server error
31
+ */
32
+ http.route({
33
+ path: "/api/dink/applyDelta",
34
+ method: "POST",
35
+ handler: httpAction(async (ctx, request) => {
36
+ // Validate Authorization header
37
+ const authHeader = request.headers.get("Authorization");
38
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
39
+ return new Response(
40
+ JSON.stringify({
41
+ success: false,
42
+ error: "Missing or invalid Authorization header",
43
+ }),
44
+ {
45
+ status: 401,
46
+ headers: { "Content-Type": "application/json" },
47
+ }
48
+ );
49
+ }
50
+
51
+ const token = authHeader.slice(7).trim();
52
+ if (!token) {
53
+ return new Response(
54
+ JSON.stringify({
55
+ success: false,
56
+ error: "Missing authorization token",
57
+ }),
58
+ {
59
+ status: 401,
60
+ headers: { "Content-Type": "application/json" },
61
+ }
62
+ );
63
+ }
64
+
65
+ // Parse request body
66
+ let payload: { collection?: string; docId?: string; bytes?: number[]; edgeId?: string };
67
+ try {
68
+ payload = await request.json();
69
+ } catch {
70
+ return new Response(
71
+ JSON.stringify({
72
+ success: false,
73
+ error: "Invalid JSON payload",
74
+ }),
75
+ {
76
+ status: 400,
77
+ headers: { "Content-Type": "application/json" },
78
+ }
79
+ );
80
+ }
81
+
82
+ // Validate payload
83
+ if (!payload.collection || typeof payload.collection !== "string") {
84
+ return new Response(
85
+ JSON.stringify({
86
+ success: false,
87
+ error: "Missing or invalid collection field",
88
+ }),
89
+ {
90
+ status: 400,
91
+ headers: { "Content-Type": "application/json" },
92
+ }
93
+ );
94
+ }
95
+
96
+ if (!payload.docId || typeof payload.docId !== "string") {
97
+ return new Response(
98
+ JSON.stringify({
99
+ success: false,
100
+ error: "Missing or invalid docId field",
101
+ }),
102
+ {
103
+ status: 400,
104
+ headers: { "Content-Type": "application/json" },
105
+ }
106
+ );
107
+ }
108
+
109
+ if (!Array.isArray(payload.bytes)) {
110
+ return new Response(
111
+ JSON.stringify({
112
+ success: false,
113
+ error: "Missing or invalid bytes field",
114
+ }),
115
+ {
116
+ status: 400,
117
+ headers: { "Content-Type": "application/json" },
118
+ }
119
+ );
120
+ }
121
+
122
+ // Convert bytes array to ArrayBuffer for Convex
123
+ const bytes = new Uint8Array(payload.bytes).buffer;
124
+
125
+ try {
126
+ // Call the internal mutation that skips outbox
127
+ // The mutation uses ctx.db directly (not wrapped with triggers)
128
+ // to prevent sync loops back to edge
129
+ const result = await ctx.runMutation(api.sync.applyDeltaFromEdge, {
130
+ collection: payload.collection,
131
+ docId: payload.docId,
132
+ bytes,
133
+ authToken: token,
134
+ edgeId: payload.edgeId,
135
+ });
136
+
137
+ return new Response(
138
+ JSON.stringify({
139
+ success: true,
140
+ seq: result.seq,
141
+ }),
142
+ {
143
+ status: 200,
144
+ headers: { "Content-Type": "application/json" },
145
+ }
146
+ );
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : "Unknown error";
149
+ return new Response(
150
+ JSON.stringify({
151
+ success: false,
152
+ error: message,
153
+ }),
154
+ {
155
+ status: 500,
156
+ headers: { "Content-Type": "application/json" },
157
+ }
158
+ );
159
+ }
160
+ }),
161
+ });
162
+
163
+ /**
164
+ * GET /api/dink/getSnapshot
165
+ *
166
+ * Returns the current document state as a Yjs snapshot for edge clients
167
+ * to bootstrap their local state or reconnect after disconnection.
168
+ *
169
+ * Query Parameters:
170
+ * - collection: string - The collection/table name
171
+ * - docId: string - The document sync ID
172
+ *
173
+ * Headers:
174
+ * - Authorization: Bearer <appSyncKey>
175
+ *
176
+ * Response:
177
+ * - 200: { success: true, bytes: number[], seq: number }
178
+ * - 400: { success: false, error: string } - Invalid parameters
179
+ * - 401: { success: false, error: string } - Unauthorized
180
+ * - 404: { success: false, error: string } - Document not found
181
+ * - 500: { success: false, error: string } - Server error
182
+ */
183
+ http.route({
184
+ path: "/api/dink/getSnapshot",
185
+ method: "GET",
186
+ handler: httpAction(async (ctx, request) => {
187
+ // Validate Authorization header
188
+ const authHeader = request.headers.get("Authorization");
189
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
190
+ return new Response(
191
+ JSON.stringify({
192
+ success: false,
193
+ error: "Missing or invalid Authorization header",
194
+ }),
195
+ {
196
+ status: 401,
197
+ headers: { "Content-Type": "application/json" },
198
+ }
199
+ );
200
+ }
201
+
202
+ const token = authHeader.slice(7).trim();
203
+ if (!token) {
204
+ return new Response(
205
+ JSON.stringify({
206
+ success: false,
207
+ error: "Missing authorization token",
208
+ }),
209
+ {
210
+ status: 401,
211
+ headers: { "Content-Type": "application/json" },
212
+ }
213
+ );
214
+ }
215
+
216
+ // Parse query parameters
217
+ const url = new URL(request.url);
218
+ const collection = url.searchParams.get("collection");
219
+ const docId = url.searchParams.get("docId");
220
+
221
+ // Validate query parameters
222
+ if (!collection || typeof collection !== "string") {
223
+ return new Response(
224
+ JSON.stringify({
225
+ success: false,
226
+ error: "Missing or invalid collection parameter",
227
+ }),
228
+ {
229
+ status: 400,
230
+ headers: { "Content-Type": "application/json" },
231
+ }
232
+ );
233
+ }
234
+
235
+ if (!docId || typeof docId !== "string") {
236
+ return new Response(
237
+ JSON.stringify({
238
+ success: false,
239
+ error: "Missing or invalid docId parameter",
240
+ }),
241
+ {
242
+ status: 400,
243
+ headers: { "Content-Type": "application/json" },
244
+ }
245
+ );
246
+ }
247
+
248
+ try {
249
+ // Query the document state
250
+ const result = await ctx.runQuery(api.sync.getDocumentState, {
251
+ collection,
252
+ docId,
253
+ });
254
+
255
+ // Return 404 if document not found
256
+ if (!result) {
257
+ return new Response(
258
+ JSON.stringify({
259
+ success: false,
260
+ error: "Document not found",
261
+ }),
262
+ {
263
+ status: 404,
264
+ headers: { "Content-Type": "application/json" },
265
+ }
266
+ );
267
+ }
268
+
269
+ // Convert ArrayBuffer to number array for JSON serialization
270
+ const bytes = Array.from(new Uint8Array(result.bytes));
271
+
272
+ return new Response(
273
+ JSON.stringify({
274
+ success: true,
275
+ bytes,
276
+ seq: result.seq,
277
+ }),
278
+ {
279
+ status: 200,
280
+ headers: { "Content-Type": "application/json" },
281
+ }
282
+ );
283
+ } catch (error) {
284
+ const message = error instanceof Error ? error.message : "Unknown error";
285
+ return new Response(
286
+ JSON.stringify({
287
+ success: false,
288
+ error: message,
289
+ }),
290
+ {
291
+ status: 500,
292
+ headers: { "Content-Type": "application/json" },
293
+ }
294
+ );
295
+ }
296
+ }),
297
+ });
298
+
299
+ /**
300
+ * GET /api/dink/listDocuments
301
+ *
302
+ * Returns a list of document IDs (syncId values) in a collection.
303
+ * Supports pagination via cursor parameter.
304
+ *
305
+ * Query Parameters:
306
+ * - collection: string - The collection/table name
307
+ * - cursor: string (optional) - Pagination cursor
308
+ * - limit: number (optional) - Max results per page (default: 100)
309
+ *
310
+ * Headers:
311
+ * - Authorization: Bearer <appSyncKey>
312
+ *
313
+ * Response:
314
+ * - 200: { success: true, docIds: string[], nextCursor?: string }
315
+ * - 400: { success: false, error: string } - Invalid parameters
316
+ * - 401: { success: false, error: string } - Unauthorized
317
+ * - 500: { success: false, error: string } - Server error
318
+ */
319
+ http.route({
320
+ path: "/api/dink/listDocuments",
321
+ method: "GET",
322
+ handler: httpAction(async (ctx, request) => {
323
+ // Validate Authorization header
324
+ const authHeader = request.headers.get("Authorization");
325
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
326
+ return new Response(
327
+ JSON.stringify({
328
+ success: false,
329
+ error: "Missing or invalid Authorization header",
330
+ }),
331
+ {
332
+ status: 401,
333
+ headers: { "Content-Type": "application/json" },
334
+ }
335
+ );
336
+ }
337
+
338
+ const token = authHeader.slice(7).trim();
339
+ if (!token) {
340
+ return new Response(
341
+ JSON.stringify({
342
+ success: false,
343
+ error: "Missing authorization token",
344
+ }),
345
+ {
346
+ status: 401,
347
+ headers: { "Content-Type": "application/json" },
348
+ }
349
+ );
350
+ }
351
+
352
+ // Parse query parameters
353
+ const url = new URL(request.url);
354
+ const collection = url.searchParams.get("collection");
355
+ const cursor = url.searchParams.get("cursor") || undefined;
356
+ const limitParam = url.searchParams.get("limit");
357
+ const limit = limitParam ? parseInt(limitParam, 10) : 100;
358
+
359
+ // Validate query parameters
360
+ if (!collection || typeof collection !== "string") {
361
+ return new Response(
362
+ JSON.stringify({
363
+ success: false,
364
+ error: "Missing or invalid collection parameter",
365
+ }),
366
+ {
367
+ status: 400,
368
+ headers: { "Content-Type": "application/json" },
369
+ }
370
+ );
371
+ }
372
+
373
+ if (isNaN(limit) || limit < 1) {
374
+ return new Response(
375
+ JSON.stringify({
376
+ success: false,
377
+ error: "Invalid limit parameter",
378
+ }),
379
+ {
380
+ status: 400,
381
+ headers: { "Content-Type": "application/json" },
382
+ }
383
+ );
384
+ }
385
+
386
+ try {
387
+ // Query the document list
388
+ const result = await ctx.runQuery(api.sync.listDocumentsPaginated, {
389
+ collection,
390
+ cursor,
391
+ limit,
392
+ });
393
+
394
+ return new Response(
395
+ JSON.stringify({
396
+ success: true,
397
+ docIds: result.docIds,
398
+ nextCursor: result.nextCursor,
399
+ }),
400
+ {
401
+ status: 200,
402
+ headers: { "Content-Type": "application/json" },
403
+ }
404
+ );
405
+ } catch (error) {
406
+ const message = error instanceof Error ? error.message : "Unknown error";
407
+ return new Response(
408
+ JSON.stringify({
409
+ success: false,
410
+ error: message,
411
+ }),
412
+ {
413
+ status: 500,
414
+ headers: { "Content-Type": "application/json" },
415
+ }
416
+ );
417
+ }
418
+ }),
419
+ });
420
+
421
+ export default http;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @fatagnus/dink-convex - Internal Convex Functions
3
+ *
4
+ * This directory contains the component's internal Convex code including:
5
+ * - Internal sync tables schema
6
+ * - HTTP endpoints for dinkd communication
7
+ * - Scheduled functions for outbox processing
8
+ * - Trigger setup for intercepting writes
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ // Placeholder exports - will be implemented in subsequent stories
14
+ // US-003: Schema
15
+ // US-004: Triggers
16
+ // US-006-008: HTTP endpoints
17
+ // US-009: Scheduled functions
18
+
19
+ export const COMPONENT_NAME = "@fatagnus/dink-convex";
20
+ export const COMPONENT_VERSION = "0.1.0";
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Installation and configuration mutations for @fatagnus/dink-convex component.
3
+ *
4
+ * Provides functions to enable/disable sync for specific collections
5
+ * by managing the _sync_config table.
6
+ *
7
+ * @module convex/install
8
+ */
9
+
10
+ import { v } from "convex/values";
11
+ import { mutation, query } from "./_generated/server";
12
+
13
+ /**
14
+ * Enable sync for a collection.
15
+ *
16
+ * Registers the collection in _sync_config with enabled=true.
17
+ * If the collection is already registered, updates it to enabled.
18
+ *
19
+ * @param collection - The name of the collection/table to enable sync for
20
+ * @returns Object with success status
21
+ */
22
+ export const enableSync = mutation({
23
+ args: {
24
+ collection: v.string(),
25
+ },
26
+ returns: v.object({
27
+ success: v.boolean(),
28
+ message: v.string(),
29
+ }),
30
+ handler: async (ctx, args) => {
31
+ // Check if collection already exists in _sync_config
32
+ const existing = await ctx.db
33
+ .query("sync_config")
34
+ .withIndex("by_collection", (q) => q.eq("collection", args.collection))
35
+ .first();
36
+
37
+ if (existing) {
38
+ // Update existing config to enabled
39
+ if (!existing.enabled) {
40
+ await ctx.db.patch(existing._id, { enabled: true });
41
+ return {
42
+ success: true,
43
+ message: `Sync enabled for collection: ${args.collection}`,
44
+ };
45
+ }
46
+ return {
47
+ success: true,
48
+ message: `Sync already enabled for collection: ${args.collection}`,
49
+ };
50
+ }
51
+
52
+ // Create new config entry
53
+ await ctx.db.insert("sync_config", {
54
+ collection: args.collection,
55
+ enabled: true,
56
+ });
57
+
58
+ return {
59
+ success: true,
60
+ message: `Sync enabled for collection: ${args.collection}`,
61
+ };
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Disable sync for a collection.
67
+ *
68
+ * Updates the collection's _sync_config entry to enabled=false.
69
+ * Does not delete the config entry to preserve history.
70
+ *
71
+ * @param collection - The name of the collection/table to disable sync for
72
+ * @returns Object with success status
73
+ */
74
+ export const disableSync = mutation({
75
+ args: {
76
+ collection: v.string(),
77
+ },
78
+ returns: v.object({
79
+ success: v.boolean(),
80
+ message: v.string(),
81
+ }),
82
+ handler: async (ctx, args) => {
83
+ // Check if collection exists in _sync_config
84
+ const existing = await ctx.db
85
+ .query("sync_config")
86
+ .withIndex("by_collection", (q) => q.eq("collection", args.collection))
87
+ .first();
88
+
89
+ if (!existing) {
90
+ return {
91
+ success: false,
92
+ message: `Collection not found in sync config: ${args.collection}`,
93
+ };
94
+ }
95
+
96
+ if (!existing.enabled) {
97
+ return {
98
+ success: true,
99
+ message: `Sync already disabled for collection: ${args.collection}`,
100
+ };
101
+ }
102
+
103
+ // Update to disabled
104
+ await ctx.db.patch(existing._id, { enabled: false });
105
+
106
+ return {
107
+ success: true,
108
+ message: `Sync disabled for collection: ${args.collection}`,
109
+ };
110
+ },
111
+ });
112
+
113
+ /**
114
+ * Get sync status for a collection.
115
+ *
116
+ * Queries _sync_config to determine if sync is enabled for the collection.
117
+ *
118
+ * @param collection - The name of the collection/table to check
119
+ * @returns Object with enabled status, or null if not configured
120
+ */
121
+ export const getSyncStatus = query({
122
+ args: {
123
+ collection: v.string(),
124
+ },
125
+ returns: v.union(
126
+ v.object({
127
+ collection: v.string(),
128
+ enabled: v.boolean(),
129
+ }),
130
+ v.null()
131
+ ),
132
+ handler: async (ctx, args) => {
133
+ const config = await ctx.db
134
+ .query("sync_config")
135
+ .withIndex("by_collection", (q) => q.eq("collection", args.collection))
136
+ .first();
137
+
138
+ if (!config) {
139
+ return null;
140
+ }
141
+
142
+ return {
143
+ collection: config.collection,
144
+ enabled: config.enabled,
145
+ };
146
+ },
147
+ });
148
+
149
+ /**
150
+ * List all collections with sync configuration.
151
+ *
152
+ * Returns all collections in _sync_config with their enabled status.
153
+ *
154
+ * @returns Array of collection configs
155
+ */
156
+ export const listSyncedCollections = query({
157
+ args: {},
158
+ returns: v.array(
159
+ v.object({
160
+ collection: v.string(),
161
+ enabled: v.boolean(),
162
+ })
163
+ ),
164
+ handler: async (ctx) => {
165
+ const configs = await ctx.db.query("sync_config").collect();
166
+
167
+ return configs.map((config) => ({
168
+ collection: config.collection,
169
+ enabled: config.enabled,
170
+ }));
171
+ },
172
+ });