@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/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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crdt.js","sourceRoot":"","sources":["../src/crdt.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AASzB;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QAC1C,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACnC,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,MAAM,GAA4B,EAAE,CAAC;QAC3C,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;YAC3C,MAAM,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC;QAChC,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,mBAAmB,CACjC,UAAkB,EAClB,KAAa,EACb,IAAkB;IAElB,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC;IACzB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAErC,eAAe;IACf,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IACtC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAE5B,sBAAsB;IACtB,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;IACzC,CAAC;IAED,OAAO,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,mBAAmB,CACjC,UAAkB,EAClB,KAAa,EACb,OAAqB,EACrB,OAAqB;IAErB,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC;IACzB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAErC,eAAe;IACf,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IACtC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAE5B,qBAAqB;IACrB,KAAK,MAAM,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACtD,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9B,4BAA4B;QAC5B,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1D,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC;IAED,sBAAsB;IACtB,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,IAAI,CAAC,CAAC,GAAG,IAAI,OAAO,CAAC,EAAE,CAAC;YACtB,MAAM,CAAC,GAAG,CAAC,YAAY,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAED,OAAO,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,mBAAmB,CACjC,UAAkB,EAClB,KAAa;IAEb,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC;IACzB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAErC,eAAe;IACf,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IACtC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAE5B,iCAAiC;IACjC,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;IAC7B,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAErC,OAAO,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACrC,CAAC"}