@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.
- package/LICENSE +190 -0
- package/README.md +282 -0
- package/convex/convex.config.ts +23 -0
- package/convex/crons.ts +37 -0
- package/convex/http.ts +421 -0
- package/convex/index.ts +20 -0
- package/convex/install.ts +172 -0
- package/convex/outbox.ts +198 -0
- package/convex/outboxProcessor.ts +240 -0
- package/convex/schema.ts +97 -0
- package/convex/sync.ts +327 -0
- package/dist/component.d.ts +34 -0
- package/dist/component.d.ts.map +1 -0
- package/dist/component.js +35 -0
- package/dist/component.js.map +1 -0
- package/dist/crdt.d.ts +82 -0
- package/dist/crdt.d.ts.map +1 -0
- package/dist/crdt.js +134 -0
- package/dist/crdt.js.map +1 -0
- package/dist/factories.d.ts +80 -0
- package/dist/factories.d.ts.map +1 -0
- package/dist/factories.js +159 -0
- package/dist/factories.js.map +1 -0
- package/dist/http.d.ts +238 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +222 -0
- package/dist/http.js.map +1 -0
- package/dist/httpFactory.d.ts +39 -0
- package/dist/httpFactory.d.ts.map +1 -0
- package/dist/httpFactory.js +128 -0
- package/dist/httpFactory.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +217 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +195 -0
- package/dist/schema.js.map +1 -0
- package/dist/syncFactories.d.ts +240 -0
- package/dist/syncFactories.d.ts.map +1 -0
- package/dist/syncFactories.js +623 -0
- package/dist/syncFactories.js.map +1 -0
- package/dist/triggers.d.ts +442 -0
- package/dist/triggers.d.ts.map +1 -0
- package/dist/triggers.js +705 -0
- package/dist/triggers.js.map +1 -0
- package/package.json +108 -0
- package/scripts/check-peer-deps.cjs +132 -0
package/convex/sync.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync mutations for @fatagnus/dink-convex component.
|
|
3
|
+
*
|
|
4
|
+
* These mutations handle CRDT delta operations for bidirectional sync.
|
|
5
|
+
* The applyDeltaFromEdge mutation specifically skips outbox queueing
|
|
6
|
+
* to prevent sync loops.
|
|
7
|
+
*
|
|
8
|
+
* @module convex/sync
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { v } from "convex/values";
|
|
12
|
+
import { mutation, query } from "./_generated/server";
|
|
13
|
+
import * as Y from "yjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the next sequence number for a collection.
|
|
17
|
+
*/
|
|
18
|
+
async function getNextSeq(
|
|
19
|
+
ctx: { db: { query: (table: string) => { order: (dir: "desc") => { first: () => Promise<{ seq: number } | null> } } } },
|
|
20
|
+
collection: string
|
|
21
|
+
): Promise<number> {
|
|
22
|
+
const latest = await ctx.db
|
|
23
|
+
.query("sync_deltas")
|
|
24
|
+
.order("desc")
|
|
25
|
+
.first();
|
|
26
|
+
return (latest?.seq ?? 0) + 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apply a CRDT delta from edge device.
|
|
31
|
+
*
|
|
32
|
+
* This mutation is called by the HTTP endpoint and deliberately
|
|
33
|
+
* skips outbox queueing to prevent sync loops. It uses the raw
|
|
34
|
+
* ctx.db instead of the trigger-wrapped db.
|
|
35
|
+
*
|
|
36
|
+
* AC #5: Skips outbox queueing to prevent sync loops
|
|
37
|
+
*/
|
|
38
|
+
export const applyDeltaFromEdge = mutation({
|
|
39
|
+
args: {
|
|
40
|
+
collection: v.string(),
|
|
41
|
+
docId: v.string(),
|
|
42
|
+
bytes: v.bytes(),
|
|
43
|
+
authToken: v.string(),
|
|
44
|
+
edgeId: v.optional(v.string()),
|
|
45
|
+
},
|
|
46
|
+
returns: v.object({
|
|
47
|
+
success: v.boolean(),
|
|
48
|
+
seq: v.number(),
|
|
49
|
+
}),
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
// Validate authToken against app's sync key
|
|
52
|
+
const expectedKey = process.env.DINK_APP_SYNC_KEY;
|
|
53
|
+
if (!expectedKey) {
|
|
54
|
+
throw new Error("DINK_APP_SYNC_KEY environment variable is not configured");
|
|
55
|
+
}
|
|
56
|
+
if (!args.authToken) {
|
|
57
|
+
throw new Error("Missing authentication token");
|
|
58
|
+
}
|
|
59
|
+
if (args.authToken !== expectedKey) {
|
|
60
|
+
throw new Error("Invalid authentication token");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const seq = await getNextSeq(ctx, args.collection);
|
|
64
|
+
const timestamp = Date.now();
|
|
65
|
+
|
|
66
|
+
// Store the delta in _sync_deltas
|
|
67
|
+
// Using ctx.db directly (not wrapped with triggers) to skip outbox
|
|
68
|
+
await ctx.db.insert("sync_deltas", {
|
|
69
|
+
collection: args.collection,
|
|
70
|
+
docId: args.docId,
|
|
71
|
+
bytes: args.bytes,
|
|
72
|
+
seq,
|
|
73
|
+
timestamp,
|
|
74
|
+
edgeId: args.edgeId,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// NOTE: We intentionally do NOT insert into _sync_outbox here
|
|
78
|
+
// This delta came from edge, so we don't need to sync it back
|
|
79
|
+
// This is AC #5: Skips outbox queueing to prevent sync loops
|
|
80
|
+
|
|
81
|
+
// === User Table Materialization ===
|
|
82
|
+
// Decode the Yjs delta and write to the actual user table
|
|
83
|
+
await materializeToUserTable(ctx.db, args.collection, args.docId, args.bytes);
|
|
84
|
+
|
|
85
|
+
return { success: true, seq };
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Internal fields that should not be written to user tables.
|
|
91
|
+
* These are sync metadata fields used for tracking.
|
|
92
|
+
*/
|
|
93
|
+
const INTERNAL_SYNC_FIELDS = new Set([
|
|
94
|
+
"_collection",
|
|
95
|
+
"syncId",
|
|
96
|
+
"_deleted",
|
|
97
|
+
"_deletedAt",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract user-facing fields from Yjs document, excluding internal sync metadata.
|
|
102
|
+
*/
|
|
103
|
+
function extractUserFields(fields: Y.Map<unknown>): Record<string, unknown> {
|
|
104
|
+
const userFields: Record<string, unknown> = {};
|
|
105
|
+
fields.forEach((value, key) => {
|
|
106
|
+
if (!INTERNAL_SYNC_FIELDS.has(key) && !key.startsWith("_removed_")) {
|
|
107
|
+
userFields[key] = value;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return userFields;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Materialize a CRDT delta to the user table.
|
|
115
|
+
*
|
|
116
|
+
* This function decodes the Yjs delta bytes and performs the appropriate
|
|
117
|
+
* database operation (insert, update, or delete) on the user table.
|
|
118
|
+
* Uses raw ctx.db to avoid triggering sync outbox.
|
|
119
|
+
*
|
|
120
|
+
* @param db - Database context (unwrapped, no triggers)
|
|
121
|
+
* @param collection - The user table name
|
|
122
|
+
* @param docId - The document's syncId
|
|
123
|
+
* @param bytes - The Yjs CRDT delta bytes
|
|
124
|
+
*/
|
|
125
|
+
async function materializeToUserTable(
|
|
126
|
+
db: {
|
|
127
|
+
query: (table: string) => {
|
|
128
|
+
withIndex: (
|
|
129
|
+
indexName: string,
|
|
130
|
+
queryFn: (q: { eq: (field: string, value: string) => unknown }) => unknown
|
|
131
|
+
) => {
|
|
132
|
+
first: () => Promise<{ _id: string; [key: string]: unknown } | null>;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
insert: (table: string, doc: Record<string, unknown>) => Promise<string>;
|
|
136
|
+
patch: (id: string, fields: Record<string, unknown>) => Promise<void>;
|
|
137
|
+
delete: (id: string) => Promise<void>;
|
|
138
|
+
},
|
|
139
|
+
collection: string,
|
|
140
|
+
docId: string,
|
|
141
|
+
bytes: ArrayBuffer
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
// Decode the Yjs delta
|
|
144
|
+
const ydoc = new Y.Doc();
|
|
145
|
+
try {
|
|
146
|
+
Y.applyUpdate(ydoc, new Uint8Array(bytes));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Invalid CRDT delta bytes for document "${docId}" in collection "${collection}": ${message}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const fields = ydoc.getMap("fields");
|
|
154
|
+
|
|
155
|
+
// Check if this is a delete operation
|
|
156
|
+
const isDeleted = fields.get("_deleted") === true;
|
|
157
|
+
|
|
158
|
+
// Find existing document by syncId
|
|
159
|
+
const existing = await db
|
|
160
|
+
.query(collection)
|
|
161
|
+
.withIndex("by_syncId", (q) => q.eq("syncId", docId))
|
|
162
|
+
.first();
|
|
163
|
+
|
|
164
|
+
if (isDeleted) {
|
|
165
|
+
// Delete operation: remove from user table if exists
|
|
166
|
+
if (existing) {
|
|
167
|
+
await db.delete(existing._id as string);
|
|
168
|
+
}
|
|
169
|
+
// If document doesn't exist, nothing to delete - this is fine
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extract user-facing fields (exclude internal sync metadata)
|
|
174
|
+
const userFields = extractUserFields(fields);
|
|
175
|
+
|
|
176
|
+
if (existing) {
|
|
177
|
+
// Update operation: patch existing document
|
|
178
|
+
// Handle removed fields by setting them to undefined
|
|
179
|
+
const patchFields: Record<string, unknown> = { ...userFields };
|
|
180
|
+
fields.forEach((value, key) => {
|
|
181
|
+
if (key.startsWith("_removed_") && value === true) {
|
|
182
|
+
const removedField = key.slice("_removed_".length);
|
|
183
|
+
patchFields[removedField] = undefined;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
await db.patch(existing._id as string, patchFields);
|
|
187
|
+
} else {
|
|
188
|
+
// Insert operation: create new document with syncId
|
|
189
|
+
await db.insert(collection, {
|
|
190
|
+
...userFields,
|
|
191
|
+
syncId: docId,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get document state for a specific document.
|
|
198
|
+
* Returns the merged CRDT state from all deltas.
|
|
199
|
+
*/
|
|
200
|
+
export const getDocumentState = query({
|
|
201
|
+
args: {
|
|
202
|
+
collection: v.string(),
|
|
203
|
+
docId: v.string(),
|
|
204
|
+
},
|
|
205
|
+
returns: v.union(
|
|
206
|
+
v.object({
|
|
207
|
+
bytes: v.bytes(),
|
|
208
|
+
seq: v.number(),
|
|
209
|
+
}),
|
|
210
|
+
v.null()
|
|
211
|
+
),
|
|
212
|
+
handler: async (ctx, args) => {
|
|
213
|
+
// Get all deltas for this document
|
|
214
|
+
const deltas = await ctx.db
|
|
215
|
+
.query("sync_deltas")
|
|
216
|
+
.withIndex("by_docId", (q) =>
|
|
217
|
+
q.eq("collection", args.collection).eq("docId", args.docId)
|
|
218
|
+
)
|
|
219
|
+
.collect();
|
|
220
|
+
|
|
221
|
+
if (deltas.length === 0) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Find the latest seq
|
|
226
|
+
let latestSeq = 0;
|
|
227
|
+
for (const delta of deltas) {
|
|
228
|
+
latestSeq = Math.max(latestSeq, delta.seq);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// For now, return the latest delta's bytes
|
|
232
|
+
// In production, we'd merge all deltas using Yjs
|
|
233
|
+
const latestDelta = deltas.find((d) => d.seq === latestSeq);
|
|
234
|
+
if (!latestDelta) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
bytes: latestDelta.bytes,
|
|
240
|
+
seq: latestSeq,
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* List all documents in a collection with their latest sequence numbers.
|
|
247
|
+
*/
|
|
248
|
+
export const listDocuments = query({
|
|
249
|
+
args: {
|
|
250
|
+
collection: v.string(),
|
|
251
|
+
},
|
|
252
|
+
returns: v.array(
|
|
253
|
+
v.object({
|
|
254
|
+
docId: v.string(),
|
|
255
|
+
seq: v.number(),
|
|
256
|
+
})
|
|
257
|
+
),
|
|
258
|
+
handler: async (ctx, args) => {
|
|
259
|
+
// Get all deltas for this collection
|
|
260
|
+
const deltas = await ctx.db
|
|
261
|
+
.query("sync_deltas")
|
|
262
|
+
.withIndex("by_collection", (q) => q.eq("collection", args.collection))
|
|
263
|
+
.collect();
|
|
264
|
+
|
|
265
|
+
// Build a map of docId -> latest seq
|
|
266
|
+
const docMap = new Map<string, number>();
|
|
267
|
+
for (const delta of deltas) {
|
|
268
|
+
const existing = docMap.get(delta.docId) ?? 0;
|
|
269
|
+
docMap.set(delta.docId, Math.max(existing, delta.seq));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Convert to array
|
|
273
|
+
return Array.from(docMap.entries()).map(([docId, seq]) => ({
|
|
274
|
+
docId,
|
|
275
|
+
seq,
|
|
276
|
+
}));
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* List documents in a collection with pagination support.
|
|
282
|
+
* Returns document IDs (syncId values) and a cursor for the next page.
|
|
283
|
+
*/
|
|
284
|
+
export const listDocumentsPaginated = query({
|
|
285
|
+
args: {
|
|
286
|
+
collection: v.string(),
|
|
287
|
+
cursor: v.optional(v.string()),
|
|
288
|
+
limit: v.optional(v.number()),
|
|
289
|
+
},
|
|
290
|
+
returns: v.object({
|
|
291
|
+
docIds: v.array(v.string()),
|
|
292
|
+
nextCursor: v.optional(v.string()),
|
|
293
|
+
}),
|
|
294
|
+
handler: async (ctx, args) => {
|
|
295
|
+
const limit = args.limit ?? 100;
|
|
296
|
+
const offset = args.cursor ? parseInt(args.cursor, 10) : 0;
|
|
297
|
+
|
|
298
|
+
// Validate cursor is a valid non-negative integer
|
|
299
|
+
if (isNaN(offset) || offset < 0) {
|
|
300
|
+
throw new Error("Invalid cursor");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Get all deltas for this collection
|
|
304
|
+
const deltas = await ctx.db
|
|
305
|
+
.query("sync_deltas")
|
|
306
|
+
.withIndex("by_collection", (q) => q.eq("collection", args.collection))
|
|
307
|
+
.collect();
|
|
308
|
+
|
|
309
|
+
// Build a set of unique docIds
|
|
310
|
+
const docIdSet = new Set<string>();
|
|
311
|
+
for (const delta of deltas) {
|
|
312
|
+
docIdSet.add(delta.docId);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Convert to sorted array for consistent pagination
|
|
316
|
+
const allDocIds = Array.from(docIdSet).sort();
|
|
317
|
+
|
|
318
|
+
// Apply pagination
|
|
319
|
+
const paginatedDocIds = allDocIds.slice(offset, offset + limit);
|
|
320
|
+
const hasMore = offset + limit < allDocIds.length;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
docIds: paginatedDocIds,
|
|
324
|
+
nextCursor: hasMore ? String(offset + limit) : undefined,
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fatagnus/dink-convex Component Definition
|
|
3
|
+
*
|
|
4
|
+
* This file defines the Dink Convex sync component that can be installed
|
|
5
|
+
* in a Convex app using app.use(dink) in convex.config.ts.
|
|
6
|
+
*
|
|
7
|
+
* The component bundles:
|
|
8
|
+
* - Internal sync tables (_sync_deltas, _sync_outbox, _sync_sessions, _sync_config)
|
|
9
|
+
* - HTTP endpoints for dinkd communication
|
|
10
|
+
* - Scheduled functions for outbox processing
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Dink Convex Sync Component
|
|
16
|
+
*
|
|
17
|
+
* Provides bidirectional sync between Convex and edge devices using CRDT deltas.
|
|
18
|
+
* Install this component in your convex.config.ts:
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* // convex/convex.config.ts
|
|
23
|
+
* import { defineApp } from "convex/server";
|
|
24
|
+
* import dink from "@fatagnus/dink-convex/component";
|
|
25
|
+
*
|
|
26
|
+
* const app = defineApp();
|
|
27
|
+
* app.use(dink);
|
|
28
|
+
*
|
|
29
|
+
* export default app;
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
declare const component: import("convex/server").ComponentDefinition<any>;
|
|
33
|
+
export default component;
|
|
34
|
+
//# sourceMappingURL=component.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH;;;;;;;;;;;;;;;;;GAiBG;AACH,QAAA,MAAM,SAAS,kDAA0B,CAAC;AAE1C,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fatagnus/dink-convex Component Definition
|
|
3
|
+
*
|
|
4
|
+
* This file defines the Dink Convex sync component that can be installed
|
|
5
|
+
* in a Convex app using app.use(dink) in convex.config.ts.
|
|
6
|
+
*
|
|
7
|
+
* The component bundles:
|
|
8
|
+
* - Internal sync tables (_sync_deltas, _sync_outbox, _sync_sessions, _sync_config)
|
|
9
|
+
* - HTTP endpoints for dinkd communication
|
|
10
|
+
* - Scheduled functions for outbox processing
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
import { defineComponent } from "convex/server";
|
|
15
|
+
/**
|
|
16
|
+
* Dink Convex Sync Component
|
|
17
|
+
*
|
|
18
|
+
* Provides bidirectional sync between Convex and edge devices using CRDT deltas.
|
|
19
|
+
* Install this component in your convex.config.ts:
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* // convex/convex.config.ts
|
|
24
|
+
* import { defineApp } from "convex/server";
|
|
25
|
+
* import dink from "@fatagnus/dink-convex/component";
|
|
26
|
+
*
|
|
27
|
+
* const app = defineApp();
|
|
28
|
+
* app.use(dink);
|
|
29
|
+
*
|
|
30
|
+
* export default app;
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
const component = defineComponent("dink");
|
|
34
|
+
export default component;
|
|
35
|
+
//# sourceMappingURL=component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"component.js","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,SAAS,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;AAE1C,eAAe,SAAS,CAAC"}
|
package/dist/crdt.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT delta generation using Yjs.
|
|
3
|
+
*
|
|
4
|
+
* Generates Yjs CRDT deltas for document operations (insert, update, delete)
|
|
5
|
+
* compatible with the edge SDK YjsDocument format.
|
|
6
|
+
*
|
|
7
|
+
* @module crdt
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Document data type for CRDT operations.
|
|
11
|
+
*/
|
|
12
|
+
export interface DocumentData {
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Serialize a value to a format suitable for Yjs.
|
|
17
|
+
* Handles nested objects, arrays, and primitive types.
|
|
18
|
+
*
|
|
19
|
+
* @param value - The value to serialize
|
|
20
|
+
* @returns The serialized value
|
|
21
|
+
*/
|
|
22
|
+
export declare function serializeValue(value: unknown): unknown;
|
|
23
|
+
/**
|
|
24
|
+
* Generate a CRDT delta for a newly inserted document.
|
|
25
|
+
*
|
|
26
|
+
* Creates a Yjs document with all fields set in the "fields" map,
|
|
27
|
+
* including _collection and syncId metadata for sync tracking.
|
|
28
|
+
*
|
|
29
|
+
* @param collection - The collection/table name
|
|
30
|
+
* @param docId - The document sync ID
|
|
31
|
+
* @param data - The document data to insert
|
|
32
|
+
* @returns CRDT delta as Uint8Array (Yjs update bytes)
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const delta = generateInsertDelta("tasks", "sync-123", {
|
|
37
|
+
* title: "My Task",
|
|
38
|
+
* completed: false,
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function generateInsertDelta(collection: string, docId: string, data: DocumentData): Uint8Array;
|
|
43
|
+
/**
|
|
44
|
+
* Generate a CRDT delta for an updated document.
|
|
45
|
+
*
|
|
46
|
+
* Creates a delta containing the changed fields, plus metadata.
|
|
47
|
+
* Removed fields are marked with a special _removed_ prefix.
|
|
48
|
+
*
|
|
49
|
+
* @param collection - The collection/table name
|
|
50
|
+
* @param docId - The document sync ID
|
|
51
|
+
* @param oldData - The document data before update
|
|
52
|
+
* @param newData - The document data after update
|
|
53
|
+
* @returns CRDT delta as Uint8Array (Yjs update bytes)
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* const delta = generateUpdateDelta(
|
|
58
|
+
* "tasks",
|
|
59
|
+
* "sync-123",
|
|
60
|
+
* { title: "Old Title" },
|
|
61
|
+
* { title: "New Title" }
|
|
62
|
+
* );
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function generateUpdateDelta(collection: string, docId: string, oldData: DocumentData, newData: DocumentData): Uint8Array;
|
|
66
|
+
/**
|
|
67
|
+
* Generate a tombstone CRDT delta for a deleted document.
|
|
68
|
+
*
|
|
69
|
+
* Creates a special delta marking the document as deleted,
|
|
70
|
+
* including a deletion timestamp for conflict resolution.
|
|
71
|
+
*
|
|
72
|
+
* @param collection - The collection/table name
|
|
73
|
+
* @param docId - The document sync ID
|
|
74
|
+
* @returns CRDT delta as Uint8Array (Yjs tombstone bytes)
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const delta = generateDeleteDelta("tasks", "sync-123");
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export declare function generateDeleteDelta(collection: string, docId: string): Uint8Array;
|
|
82
|
+
//# sourceMappingURL=crdt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crdt.d.ts","sourceRoot":"","sources":["../src/crdt.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAetD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,YAAY,GACjB,UAAU,CAcZ;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,YAAY,EACrB,OAAO,EAAE,YAAY,GACpB,UAAU,CAyBZ;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,GACZ,UAAU,CAaZ"}
|
package/dist/crdt.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT delta generation using Yjs.
|
|
3
|
+
*
|
|
4
|
+
* Generates Yjs CRDT deltas for document operations (insert, update, delete)
|
|
5
|
+
* compatible with the edge SDK YjsDocument format.
|
|
6
|
+
*
|
|
7
|
+
* @module crdt
|
|
8
|
+
*/
|
|
9
|
+
import * as Y from "yjs";
|
|
10
|
+
/**
|
|
11
|
+
* Serialize a value to a format suitable for Yjs.
|
|
12
|
+
* Handles nested objects, arrays, and primitive types.
|
|
13
|
+
*
|
|
14
|
+
* @param value - The value to serialize
|
|
15
|
+
* @returns The serialized value
|
|
16
|
+
*/
|
|
17
|
+
export function serializeValue(value) {
|
|
18
|
+
if (value === null || value === undefined) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
return value.map(serializeValue);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === "object") {
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const [k, v] of Object.entries(value)) {
|
|
27
|
+
result[k] = serializeValue(v);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generate a CRDT delta for a newly inserted document.
|
|
35
|
+
*
|
|
36
|
+
* Creates a Yjs document with all fields set in the "fields" map,
|
|
37
|
+
* including _collection and syncId metadata for sync tracking.
|
|
38
|
+
*
|
|
39
|
+
* @param collection - The collection/table name
|
|
40
|
+
* @param docId - The document sync ID
|
|
41
|
+
* @param data - The document data to insert
|
|
42
|
+
* @returns CRDT delta as Uint8Array (Yjs update bytes)
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const delta = generateInsertDelta("tasks", "sync-123", {
|
|
47
|
+
* title: "My Task",
|
|
48
|
+
* completed: false,
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function generateInsertDelta(collection, docId, data) {
|
|
53
|
+
const ydoc = new Y.Doc();
|
|
54
|
+
const fields = ydoc.getMap("fields");
|
|
55
|
+
// Add metadata
|
|
56
|
+
fields.set("_collection", collection);
|
|
57
|
+
fields.set("syncId", docId);
|
|
58
|
+
// Add all data fields
|
|
59
|
+
for (const [key, value] of Object.entries(data)) {
|
|
60
|
+
fields.set(key, serializeValue(value));
|
|
61
|
+
}
|
|
62
|
+
return Y.encodeStateAsUpdate(ydoc);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Generate a CRDT delta for an updated document.
|
|
66
|
+
*
|
|
67
|
+
* Creates a delta containing the changed fields, plus metadata.
|
|
68
|
+
* Removed fields are marked with a special _removed_ prefix.
|
|
69
|
+
*
|
|
70
|
+
* @param collection - The collection/table name
|
|
71
|
+
* @param docId - The document sync ID
|
|
72
|
+
* @param oldData - The document data before update
|
|
73
|
+
* @param newData - The document data after update
|
|
74
|
+
* @returns CRDT delta as Uint8Array (Yjs update bytes)
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const delta = generateUpdateDelta(
|
|
79
|
+
* "tasks",
|
|
80
|
+
* "sync-123",
|
|
81
|
+
* { title: "Old Title" },
|
|
82
|
+
* { title: "New Title" }
|
|
83
|
+
* );
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function generateUpdateDelta(collection, docId, oldData, newData) {
|
|
87
|
+
const ydoc = new Y.Doc();
|
|
88
|
+
const fields = ydoc.getMap("fields");
|
|
89
|
+
// Add metadata
|
|
90
|
+
fields.set("_collection", collection);
|
|
91
|
+
fields.set("syncId", docId);
|
|
92
|
+
// Add changed fields
|
|
93
|
+
for (const [key, newValue] of Object.entries(newData)) {
|
|
94
|
+
const oldValue = oldData[key];
|
|
95
|
+
// Include if changed or new
|
|
96
|
+
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
97
|
+
fields.set(key, serializeValue(newValue));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Mark removed fields
|
|
101
|
+
for (const key of Object.keys(oldData)) {
|
|
102
|
+
if (!(key in newData)) {
|
|
103
|
+
fields.set(`_removed_${key}`, true);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Y.encodeStateAsUpdate(ydoc);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Generate a tombstone CRDT delta for a deleted document.
|
|
110
|
+
*
|
|
111
|
+
* Creates a special delta marking the document as deleted,
|
|
112
|
+
* including a deletion timestamp for conflict resolution.
|
|
113
|
+
*
|
|
114
|
+
* @param collection - The collection/table name
|
|
115
|
+
* @param docId - The document sync ID
|
|
116
|
+
* @returns CRDT delta as Uint8Array (Yjs tombstone bytes)
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* const delta = generateDeleteDelta("tasks", "sync-123");
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function generateDeleteDelta(collection, docId) {
|
|
124
|
+
const ydoc = new Y.Doc();
|
|
125
|
+
const fields = ydoc.getMap("fields");
|
|
126
|
+
// Add metadata
|
|
127
|
+
fields.set("_collection", collection);
|
|
128
|
+
fields.set("syncId", docId);
|
|
129
|
+
// Mark as deleted with tombstone
|
|
130
|
+
fields.set("_deleted", true);
|
|
131
|
+
fields.set("_deletedAt", Date.now());
|
|
132
|
+
return Y.encodeStateAsUpdate(ydoc);
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=crdt.js.map
|
package/dist/crdt.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crdt.js","sourceRoot":"","sources":["../src/crdt.ts"],"names":[],"mappings":"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"}
|