@trestleinc/replicate 0.1.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 +201 -0
- package/LICENSE.package +201 -0
- package/README.md +871 -0
- package/dist/client/collection.d.ts +94 -0
- package/dist/client/index.d.ts +18 -0
- package/dist/client/logger.d.ts +3 -0
- package/dist/client/storage.d.ts +143 -0
- package/dist/component/_generated/api.js +5 -0
- package/dist/component/_generated/server.js +9 -0
- package/dist/component/convex.config.d.ts +2 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/public.d.ts +99 -0
- package/dist/component/public.js +135 -0
- package/dist/component/schema.d.ts +22 -0
- package/dist/component/schema.js +22 -0
- package/dist/index.js +375 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/replication.d.ts +122 -0
- package/dist/server/schema.d.ts +73 -0
- package/dist/server/ssr.d.ts +79 -0
- package/dist/server.js +96 -0
- package/dist/ssr.js +19 -0
- package/package.json +108 -0
- package/src/client/collection.ts +550 -0
- package/src/client/index.ts +31 -0
- package/src/client/logger.ts +31 -0
- package/src/client/storage.ts +206 -0
- package/src/component/_generated/api.d.ts +95 -0
- package/src/component/_generated/api.js +23 -0
- package/src/component/_generated/dataModel.d.ts +60 -0
- package/src/component/_generated/server.d.ts +149 -0
- package/src/component/_generated/server.js +90 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/public.ts +212 -0
- package/src/component/schema.ts +16 -0
- package/src/server/index.ts +26 -0
- package/src/server/replication.ts +244 -0
- package/src/server/schema.ts +97 -0
- package/src/server/ssr.ts +106 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { v } from 'convex/values';
|
|
2
|
+
import { mutation, query } from './_generated/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Insert a new document with CRDT bytes (Yjs format).
|
|
6
|
+
* Appends delta to event log (event sourcing pattern).
|
|
7
|
+
*
|
|
8
|
+
* @param collectionName - Collection identifier
|
|
9
|
+
* @param documentId - Unique document identifier
|
|
10
|
+
* @param crdtBytes - ArrayBuffer containing Yjs CRDT bytes (delta)
|
|
11
|
+
* @param version - CRDT version number
|
|
12
|
+
*/
|
|
13
|
+
export const insertDocument = mutation({
|
|
14
|
+
args: {
|
|
15
|
+
collectionName: v.string(),
|
|
16
|
+
documentId: v.string(),
|
|
17
|
+
crdtBytes: v.bytes(),
|
|
18
|
+
version: v.number(),
|
|
19
|
+
},
|
|
20
|
+
returns: v.object({
|
|
21
|
+
success: v.boolean(),
|
|
22
|
+
}),
|
|
23
|
+
handler: async (ctx, args) => {
|
|
24
|
+
// Append delta to event log (no duplicate check - event sourcing!)
|
|
25
|
+
await ctx.db.insert('documents', {
|
|
26
|
+
collectionName: args.collectionName,
|
|
27
|
+
documentId: args.documentId,
|
|
28
|
+
crdtBytes: args.crdtBytes,
|
|
29
|
+
version: args.version,
|
|
30
|
+
timestamp: Date.now(),
|
|
31
|
+
operationType: 'insert',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return { success: true };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Update an existing document with new CRDT bytes (Yjs format).
|
|
40
|
+
* Appends delta to event log (event sourcing pattern).
|
|
41
|
+
*
|
|
42
|
+
* @param collectionName - Collection identifier
|
|
43
|
+
* @param documentId - Unique document identifier
|
|
44
|
+
* @param crdtBytes - ArrayBuffer containing Yjs CRDT bytes (delta)
|
|
45
|
+
* @param version - CRDT version number
|
|
46
|
+
*/
|
|
47
|
+
export const updateDocument = mutation({
|
|
48
|
+
args: {
|
|
49
|
+
collectionName: v.string(),
|
|
50
|
+
documentId: v.string(),
|
|
51
|
+
crdtBytes: v.bytes(),
|
|
52
|
+
version: v.number(),
|
|
53
|
+
},
|
|
54
|
+
returns: v.object({
|
|
55
|
+
success: v.boolean(),
|
|
56
|
+
}),
|
|
57
|
+
handler: async (ctx, args) => {
|
|
58
|
+
// Append delta to event log (no check - event sourcing!)
|
|
59
|
+
await ctx.db.insert('documents', {
|
|
60
|
+
collectionName: args.collectionName,
|
|
61
|
+
documentId: args.documentId,
|
|
62
|
+
crdtBytes: args.crdtBytes,
|
|
63
|
+
version: args.version,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
operationType: 'update',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { success: true };
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Delete a document from CRDT storage.
|
|
74
|
+
* Appends deletion delta to event log (preserves history).
|
|
75
|
+
*
|
|
76
|
+
* @param collectionName - Collection identifier
|
|
77
|
+
* @param documentId - Unique document identifier
|
|
78
|
+
* @param crdtBytes - ArrayBuffer containing Yjs deletion delta
|
|
79
|
+
* @param version - CRDT version number
|
|
80
|
+
*/
|
|
81
|
+
export const deleteDocument = mutation({
|
|
82
|
+
args: {
|
|
83
|
+
collectionName: v.string(),
|
|
84
|
+
documentId: v.string(),
|
|
85
|
+
crdtBytes: v.bytes(),
|
|
86
|
+
version: v.number(),
|
|
87
|
+
},
|
|
88
|
+
returns: v.object({
|
|
89
|
+
success: v.boolean(),
|
|
90
|
+
}),
|
|
91
|
+
handler: async (ctx, args) => {
|
|
92
|
+
// Append deletion delta to event log (preserve history!)
|
|
93
|
+
await ctx.db.insert('documents', {
|
|
94
|
+
collectionName: args.collectionName,
|
|
95
|
+
documentId: args.documentId,
|
|
96
|
+
crdtBytes: args.crdtBytes,
|
|
97
|
+
version: args.version,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
operationType: 'delete',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { success: true };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get complete event history for a document.
|
|
108
|
+
* Returns all CRDT deltas in chronological order.
|
|
109
|
+
*
|
|
110
|
+
* Used for:
|
|
111
|
+
* - Future recovery features (client-side)
|
|
112
|
+
* - Audit trails
|
|
113
|
+
* - Debugging
|
|
114
|
+
*
|
|
115
|
+
* @param collectionName - Collection identifier
|
|
116
|
+
* @param documentId - Unique document identifier
|
|
117
|
+
*/
|
|
118
|
+
export const getDocumentHistory = query({
|
|
119
|
+
args: {
|
|
120
|
+
collectionName: v.string(),
|
|
121
|
+
documentId: v.string(),
|
|
122
|
+
},
|
|
123
|
+
returns: v.array(
|
|
124
|
+
v.object({
|
|
125
|
+
crdtBytes: v.bytes(),
|
|
126
|
+
version: v.number(),
|
|
127
|
+
timestamp: v.number(),
|
|
128
|
+
operationType: v.string(),
|
|
129
|
+
})
|
|
130
|
+
),
|
|
131
|
+
handler: async (ctx, args) => {
|
|
132
|
+
// Fetch ALL deltas for this document in chronological order
|
|
133
|
+
const deltas = await ctx.db
|
|
134
|
+
.query('documents')
|
|
135
|
+
.withIndex('by_collection_document_version', (q) =>
|
|
136
|
+
q.eq('collectionName', args.collectionName).eq('documentId', args.documentId)
|
|
137
|
+
)
|
|
138
|
+
.order('asc')
|
|
139
|
+
.collect();
|
|
140
|
+
|
|
141
|
+
return deltas.map((d) => ({
|
|
142
|
+
crdtBytes: d.crdtBytes,
|
|
143
|
+
version: d.version,
|
|
144
|
+
timestamp: d.timestamp,
|
|
145
|
+
operationType: d.operationType,
|
|
146
|
+
}));
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stream CRDT changes for incremental replication.
|
|
152
|
+
* Returns Yjs CRDT bytes for documents modified since the checkpoint.
|
|
153
|
+
* Can be used for both polling (awaitReplication) and subscriptions (live updates).
|
|
154
|
+
*
|
|
155
|
+
* @param collectionName - Collection identifier
|
|
156
|
+
* @param checkpoint - Last replication checkpoint
|
|
157
|
+
* @param limit - Maximum number of changes to return (default: 100)
|
|
158
|
+
*/
|
|
159
|
+
export const stream = query({
|
|
160
|
+
args: {
|
|
161
|
+
collectionName: v.string(),
|
|
162
|
+
checkpoint: v.object({
|
|
163
|
+
lastModified: v.number(),
|
|
164
|
+
}),
|
|
165
|
+
limit: v.optional(v.number()),
|
|
166
|
+
},
|
|
167
|
+
returns: v.object({
|
|
168
|
+
changes: v.array(
|
|
169
|
+
v.object({
|
|
170
|
+
documentId: v.string(),
|
|
171
|
+
crdtBytes: v.bytes(),
|
|
172
|
+
version: v.number(),
|
|
173
|
+
timestamp: v.number(),
|
|
174
|
+
})
|
|
175
|
+
),
|
|
176
|
+
checkpoint: v.object({
|
|
177
|
+
lastModified: v.number(),
|
|
178
|
+
}),
|
|
179
|
+
hasMore: v.boolean(),
|
|
180
|
+
}),
|
|
181
|
+
handler: async (ctx, args) => {
|
|
182
|
+
const limit = args.limit ?? 100;
|
|
183
|
+
|
|
184
|
+
const documents = await ctx.db
|
|
185
|
+
.query('documents')
|
|
186
|
+
.withIndex('by_timestamp', (q) =>
|
|
187
|
+
q.eq('collectionName', args.collectionName).gt('timestamp', args.checkpoint.lastModified)
|
|
188
|
+
)
|
|
189
|
+
.order('asc')
|
|
190
|
+
.take(limit);
|
|
191
|
+
|
|
192
|
+
const changes = documents.map((doc) => ({
|
|
193
|
+
documentId: doc.documentId,
|
|
194
|
+
crdtBytes: doc.crdtBytes,
|
|
195
|
+
version: doc.version,
|
|
196
|
+
timestamp: doc.timestamp,
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
const newCheckpoint = {
|
|
200
|
+
lastModified:
|
|
201
|
+
documents.length > 0
|
|
202
|
+
? (documents[documents.length - 1]?.timestamp ?? args.checkpoint.lastModified)
|
|
203
|
+
: args.checkpoint.lastModified,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
changes,
|
|
208
|
+
checkpoint: newCheckpoint,
|
|
209
|
+
hasMore: documents.length === limit,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from 'convex/server';
|
|
2
|
+
import { v } from 'convex/values';
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
documents: defineTable({
|
|
6
|
+
collectionName: v.string(),
|
|
7
|
+
documentId: v.string(),
|
|
8
|
+
crdtBytes: v.bytes(),
|
|
9
|
+
version: v.number(),
|
|
10
|
+
timestamp: v.number(),
|
|
11
|
+
operationType: v.string(), // 'insert' | 'update' | 'delete'
|
|
12
|
+
})
|
|
13
|
+
.index('by_collection', ['collectionName'])
|
|
14
|
+
.index('by_collection_document_version', ['collectionName', 'documentId', 'version'])
|
|
15
|
+
.index('by_timestamp', ['collectionName', 'timestamp']),
|
|
16
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side utilities for Convex backend.
|
|
3
|
+
* Import this in your Convex functions (convex/*.ts files).
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // convex/tasks.ts
|
|
8
|
+
* import {
|
|
9
|
+
* insertDocumentHelper,
|
|
10
|
+
* updateDocumentHelper,
|
|
11
|
+
* deleteDocumentHelper,
|
|
12
|
+
* streamHelper,
|
|
13
|
+
* } from '@trestleinc/replicate/server';
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Replication helpers for mutations/queries
|
|
18
|
+
export {
|
|
19
|
+
insertDocumentHelper,
|
|
20
|
+
updateDocumentHelper,
|
|
21
|
+
deleteDocumentHelper,
|
|
22
|
+
streamHelper,
|
|
23
|
+
} from './replication.js';
|
|
24
|
+
|
|
25
|
+
// Schema utilities
|
|
26
|
+
export { replicatedTable, type ReplicationFields } from './schema.js';
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { GenericDataModel } from 'convex/server';
|
|
2
|
+
|
|
3
|
+
function cleanDocument(doc: unknown): unknown {
|
|
4
|
+
return Object.fromEntries(
|
|
5
|
+
Object.entries(doc as Record<string, unknown>).filter(
|
|
6
|
+
([_, value]) => value !== undefined && value !== null
|
|
7
|
+
)
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Insert a document into both the CRDT component and the main application table.
|
|
13
|
+
*
|
|
14
|
+
* DUAL-STORAGE ARCHITECTURE:
|
|
15
|
+
* This helper implements a dual-storage pattern where documents are stored in two places:
|
|
16
|
+
*
|
|
17
|
+
* 1. Component Storage (CRDT Layer):
|
|
18
|
+
* - Stores CRDT bytes (from Yjs) for offline-first conflict resolution
|
|
19
|
+
* - Handles concurrent updates with automatic merging
|
|
20
|
+
* - Provides the source of truth for offline changes
|
|
21
|
+
*
|
|
22
|
+
* 2. Main Application Table:
|
|
23
|
+
* - Stores materialized documents for efficient querying
|
|
24
|
+
* - Used by server-side Convex functions that need to query/join data
|
|
25
|
+
* - Optimized for reactive subscriptions and complex queries
|
|
26
|
+
*
|
|
27
|
+
* WHY BOTH?
|
|
28
|
+
* - Component: Handles conflict resolution and offline replication (CRDT bytes)
|
|
29
|
+
* - Main table: Enables efficient server-side queries (materialized docs)
|
|
30
|
+
* - Similar to event sourcing: component = event log, main table = read model
|
|
31
|
+
*
|
|
32
|
+
* @param ctx - Convex mutation context
|
|
33
|
+
* @param components - Generated components from Convex
|
|
34
|
+
* @param tableName - Name of the main application table
|
|
35
|
+
* @param args - Document data with id, crdtBytes, materializedDoc, and version
|
|
36
|
+
* @returns Success indicator
|
|
37
|
+
*/
|
|
38
|
+
export async function insertDocumentHelper<_DataModel extends GenericDataModel>(
|
|
39
|
+
ctx: unknown,
|
|
40
|
+
components: unknown,
|
|
41
|
+
tableName: string,
|
|
42
|
+
args: { id: string; crdtBytes: ArrayBuffer; materializedDoc: unknown; version: number }
|
|
43
|
+
): Promise<{
|
|
44
|
+
success: boolean;
|
|
45
|
+
metadata: {
|
|
46
|
+
documentId: string;
|
|
47
|
+
timestamp: number;
|
|
48
|
+
version: number;
|
|
49
|
+
collectionName: string;
|
|
50
|
+
};
|
|
51
|
+
}> {
|
|
52
|
+
// Use consistent timestamp for both writes to enable sync matching
|
|
53
|
+
const timestamp = Date.now();
|
|
54
|
+
|
|
55
|
+
// Write CRDT bytes to component
|
|
56
|
+
await (ctx as any).runMutation((components as any).replicate.public.insertDocument, {
|
|
57
|
+
collectionName: tableName,
|
|
58
|
+
documentId: args.id,
|
|
59
|
+
crdtBytes: args.crdtBytes,
|
|
60
|
+
version: args.version,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Write materialized doc to main table
|
|
64
|
+
const db = (ctx as any).db;
|
|
65
|
+
const cleanDoc = cleanDocument(args.materializedDoc) as Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
await db.insert(tableName, {
|
|
68
|
+
id: args.id,
|
|
69
|
+
...cleanDoc,
|
|
70
|
+
version: args.version,
|
|
71
|
+
timestamp,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Return metadata for replication matching
|
|
75
|
+
return {
|
|
76
|
+
success: true,
|
|
77
|
+
metadata: {
|
|
78
|
+
documentId: args.id,
|
|
79
|
+
timestamp,
|
|
80
|
+
version: args.version,
|
|
81
|
+
collectionName: tableName,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Update a document in both the CRDT component and the main application table.
|
|
88
|
+
*
|
|
89
|
+
* @param ctx - Convex mutation context
|
|
90
|
+
* @param components - Generated components from Convex
|
|
91
|
+
* @param tableName - Name of the main application table
|
|
92
|
+
* @param args - Document data with id, crdtBytes, materializedDoc, and version
|
|
93
|
+
* @returns Success indicator
|
|
94
|
+
*/
|
|
95
|
+
export async function updateDocumentHelper<_DataModel extends GenericDataModel>(
|
|
96
|
+
ctx: unknown,
|
|
97
|
+
components: unknown,
|
|
98
|
+
tableName: string,
|
|
99
|
+
args: { id: string; crdtBytes: ArrayBuffer; materializedDoc: unknown; version: number }
|
|
100
|
+
): Promise<{
|
|
101
|
+
success: boolean;
|
|
102
|
+
metadata: {
|
|
103
|
+
documentId: string;
|
|
104
|
+
timestamp: number;
|
|
105
|
+
version: number;
|
|
106
|
+
collectionName: string;
|
|
107
|
+
};
|
|
108
|
+
}> {
|
|
109
|
+
// Use consistent timestamp for both writes to enable sync matching
|
|
110
|
+
const timestamp = Date.now();
|
|
111
|
+
|
|
112
|
+
// Write CRDT bytes to component
|
|
113
|
+
await (ctx as any).runMutation((components as any).replicate.public.updateDocument, {
|
|
114
|
+
collectionName: tableName,
|
|
115
|
+
documentId: args.id,
|
|
116
|
+
crdtBytes: args.crdtBytes,
|
|
117
|
+
version: args.version,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Update materialized doc in main table
|
|
121
|
+
const db = (ctx as any).db;
|
|
122
|
+
const existing = await db
|
|
123
|
+
.query(tableName)
|
|
124
|
+
.withIndex('by_user_id', (q: unknown) => (q as any).eq('id', args.id))
|
|
125
|
+
.first();
|
|
126
|
+
|
|
127
|
+
if (!existing) {
|
|
128
|
+
throw new Error(`Document ${args.id} not found in table ${tableName}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cleanDoc = cleanDocument(args.materializedDoc) as Record<string, unknown>;
|
|
132
|
+
|
|
133
|
+
await db.patch(existing._id, {
|
|
134
|
+
...cleanDoc,
|
|
135
|
+
version: args.version,
|
|
136
|
+
timestamp,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Return metadata for replication matching
|
|
140
|
+
return {
|
|
141
|
+
success: true,
|
|
142
|
+
metadata: {
|
|
143
|
+
documentId: args.id,
|
|
144
|
+
timestamp,
|
|
145
|
+
version: args.version,
|
|
146
|
+
collectionName: tableName,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* HARD delete a document from main table, APPEND deletion delta to component.
|
|
153
|
+
*
|
|
154
|
+
* NEW BEHAVIOR (v0.3.0):
|
|
155
|
+
* - Appends deletion delta to component event log (preserves history)
|
|
156
|
+
* - Physically removes document from main table (hard delete)
|
|
157
|
+
* - CRDT history preserved for future recovery features
|
|
158
|
+
*
|
|
159
|
+
* @param ctx - Convex mutation context
|
|
160
|
+
* @param components - Generated components from Convex
|
|
161
|
+
* @param tableName - Name of the main application table
|
|
162
|
+
* @param args - Document data with id, crdtBytes (deletion delta), and version
|
|
163
|
+
* @returns Success indicator with metadata
|
|
164
|
+
*/
|
|
165
|
+
export async function deleteDocumentHelper<_DataModel extends GenericDataModel>(
|
|
166
|
+
ctx: unknown,
|
|
167
|
+
components: unknown,
|
|
168
|
+
tableName: string,
|
|
169
|
+
args: { id: string; crdtBytes: ArrayBuffer; version: number }
|
|
170
|
+
): Promise<{
|
|
171
|
+
success: boolean;
|
|
172
|
+
metadata: {
|
|
173
|
+
documentId: string;
|
|
174
|
+
timestamp: number;
|
|
175
|
+
version: number;
|
|
176
|
+
collectionName: string;
|
|
177
|
+
};
|
|
178
|
+
}> {
|
|
179
|
+
const timestamp = Date.now();
|
|
180
|
+
|
|
181
|
+
// 1. Append deletion delta to component (event log)
|
|
182
|
+
await (ctx as any).runMutation((components as any).replicate.public.deleteDocument, {
|
|
183
|
+
collectionName: tableName,
|
|
184
|
+
documentId: args.id,
|
|
185
|
+
crdtBytes: args.crdtBytes,
|
|
186
|
+
version: args.version,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 2. HARD DELETE from main table (physical removal)
|
|
190
|
+
const db = (ctx as any).db;
|
|
191
|
+
const existing = await db
|
|
192
|
+
.query(tableName)
|
|
193
|
+
.withIndex('by_user_id', (q: unknown) => (q as any).eq('id', args.id))
|
|
194
|
+
.first();
|
|
195
|
+
|
|
196
|
+
if (existing) {
|
|
197
|
+
await db.delete(existing._id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
metadata: {
|
|
203
|
+
documentId: args.id,
|
|
204
|
+
timestamp,
|
|
205
|
+
version: args.version,
|
|
206
|
+
collectionName: tableName,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Stream document changes from the CRDT component storage.
|
|
213
|
+
*
|
|
214
|
+
* This reads CRDT bytes from the component (not the main table) to enable
|
|
215
|
+
* true Y.applyUpdate() conflict resolution on the client.
|
|
216
|
+
* Can be used for both polling (awaitReplication) and subscriptions (live updates).
|
|
217
|
+
*
|
|
218
|
+
* @param ctx - Convex query context
|
|
219
|
+
* @param components - Generated components from Convex
|
|
220
|
+
* @param tableName - Name of the collection
|
|
221
|
+
* @param args - Checkpoint and limit for pagination
|
|
222
|
+
* @returns Array of changes with CRDT bytes
|
|
223
|
+
*/
|
|
224
|
+
export async function streamHelper<_DataModel extends GenericDataModel>(
|
|
225
|
+
ctx: unknown,
|
|
226
|
+
components: unknown,
|
|
227
|
+
tableName: string,
|
|
228
|
+
args: { checkpoint: { lastModified: number }; limit?: number }
|
|
229
|
+
): Promise<{
|
|
230
|
+
changes: Array<{
|
|
231
|
+
documentId: string;
|
|
232
|
+
crdtBytes: ArrayBuffer;
|
|
233
|
+
version: number;
|
|
234
|
+
timestamp: number;
|
|
235
|
+
}>;
|
|
236
|
+
checkpoint: { lastModified: number };
|
|
237
|
+
hasMore: boolean;
|
|
238
|
+
}> {
|
|
239
|
+
return (ctx as any).runQuery((components as any).replicate.public.stream, {
|
|
240
|
+
collectionName: tableName,
|
|
241
|
+
checkpoint: args.checkpoint,
|
|
242
|
+
limit: args.limit,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema utilities for defining replicated tables.
|
|
3
|
+
* Automatically adds replication metadata fields so users don't have to.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // convex/schema.ts
|
|
8
|
+
* import { defineSchema } from 'convex/server';
|
|
9
|
+
* import { v } from 'convex/values';
|
|
10
|
+
* import { replicatedTable } from '@trestleinc/replicate/server';
|
|
11
|
+
*
|
|
12
|
+
* export default defineSchema({
|
|
13
|
+
* tasks: replicatedTable(
|
|
14
|
+
* {
|
|
15
|
+
* id: v.string(),
|
|
16
|
+
* text: v.string(),
|
|
17
|
+
* isCompleted: v.boolean(),
|
|
18
|
+
* },
|
|
19
|
+
* (table) => table
|
|
20
|
+
* .index('by_id', ['id'])
|
|
21
|
+
* .index('by_timestamp', ['timestamp'])
|
|
22
|
+
* ),
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { defineTable } from 'convex/server';
|
|
28
|
+
import { v } from 'convex/values';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Internal replication metadata fields added to every replicated table.
|
|
32
|
+
* These are managed automatically by the replication layer.
|
|
33
|
+
*/
|
|
34
|
+
export type ReplicationFields = {
|
|
35
|
+
/** Version number for conflict resolution */
|
|
36
|
+
version: number;
|
|
37
|
+
/** Last modification timestamp (Unix ms) */
|
|
38
|
+
timestamp: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wraps a table definition to automatically add replication metadata fields.
|
|
43
|
+
*
|
|
44
|
+
* Users define their business logic fields, and we inject:
|
|
45
|
+
* - `version` - For conflict resolution and CRDT versioning
|
|
46
|
+
* - `timestamp` - For incremental sync and change tracking
|
|
47
|
+
*
|
|
48
|
+
* Enables:
|
|
49
|
+
* - Dual-storage architecture (CRDT component + main table)
|
|
50
|
+
* - Conflict-free replication across clients
|
|
51
|
+
* - Hard delete support with CRDT history preservation
|
|
52
|
+
* - Event sourcing via component storage
|
|
53
|
+
*
|
|
54
|
+
* @param userFields - User's business logic fields (id, text, etc.)
|
|
55
|
+
* @param applyIndexes - Optional callback to add indexes to the table
|
|
56
|
+
* @returns TableDefinition with replication fields injected
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* // Simple table with hard delete support
|
|
61
|
+
* tasks: replicatedTable({
|
|
62
|
+
* id: v.string(),
|
|
63
|
+
* text: v.string(),
|
|
64
|
+
* })
|
|
65
|
+
*
|
|
66
|
+
* // With indexes
|
|
67
|
+
* tasks: replicatedTable(
|
|
68
|
+
* {
|
|
69
|
+
* id: v.string(),
|
|
70
|
+
* text: v.string(),
|
|
71
|
+
* },
|
|
72
|
+
* (table) => table
|
|
73
|
+
* .index('by_id', ['id'])
|
|
74
|
+
* .index('by_timestamp', ['timestamp'])
|
|
75
|
+
* )
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function replicatedTable(
|
|
79
|
+
userFields: Record<string, any>,
|
|
80
|
+
applyIndexes?: (table: any) => any
|
|
81
|
+
): any {
|
|
82
|
+
// Create table with user fields + replication metadata
|
|
83
|
+
const tableWithMetadata = defineTable({
|
|
84
|
+
...userFields,
|
|
85
|
+
|
|
86
|
+
// Injected replication fields (hidden from user's mental model)
|
|
87
|
+
version: v.number(),
|
|
88
|
+
timestamp: v.number(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Apply user-defined indexes if provided
|
|
92
|
+
if (applyIndexes) {
|
|
93
|
+
return applyIndexes(tableWithMetadata);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return tableWithMetadata;
|
|
97
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Side Rendering (SSR) Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for loading collection data during
|
|
5
|
+
* server-side rendering. Use `loadCollection` with an explicit config
|
|
6
|
+
* object for clarity and type safety.
|
|
7
|
+
*
|
|
8
|
+
* @module ssr
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { loadCollection } from '@convex-replicate/core/ssr';
|
|
12
|
+
* import { api } from '../convex/_generated/api';
|
|
13
|
+
*
|
|
14
|
+
* const tasks = await loadCollection<Task>(httpClient, {
|
|
15
|
+
* api: api.tasks,
|
|
16
|
+
* collection: 'tasks',
|
|
17
|
+
* limit: 100,
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ConvexHttpClient } from 'convex/browser';
|
|
23
|
+
import type { FunctionReference } from 'convex/server';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* API module shape expected by loadCollection.
|
|
27
|
+
*
|
|
28
|
+
* This should match the generated API module for your collection
|
|
29
|
+
* (e.g., api.tasks, api.users, etc.)
|
|
30
|
+
*/
|
|
31
|
+
export type CollectionAPI = {
|
|
32
|
+
stream: FunctionReference<'query', 'public' | 'internal'>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Configuration for loading collection data during SSR.
|
|
37
|
+
*/
|
|
38
|
+
export interface LoadCollectionConfig {
|
|
39
|
+
/** The API module for the collection (e.g., api.tasks) */
|
|
40
|
+
api: CollectionAPI;
|
|
41
|
+
/** The collection name (should match the API module name) */
|
|
42
|
+
collection: string;
|
|
43
|
+
/** Maximum number of items to load (default: 100) */
|
|
44
|
+
limit?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load collection data for server-side rendering.
|
|
49
|
+
*
|
|
50
|
+
* **IMPORTANT**: This function is currently limited because `stream` only returns
|
|
51
|
+
* CRDT bytes, not materialized documents. For most SSR use cases, it's recommended to
|
|
52
|
+
* create a separate query that reads from your main table instead.
|
|
53
|
+
*
|
|
54
|
+
* @deprecated Consider creating a dedicated SSR query instead. See example below.
|
|
55
|
+
*
|
|
56
|
+
* @param httpClient - Convex HTTP client for server-side queries
|
|
57
|
+
* @param config - Configuration object with api, collection, and options
|
|
58
|
+
* @returns Promise resolving to array of items from the collection
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* **Recommended SSR Pattern:**
|
|
62
|
+
* ```typescript
|
|
63
|
+
* // convex/tasks.ts
|
|
64
|
+
* export const list = query({
|
|
65
|
+
* handler: async (ctx) => {
|
|
66
|
+
* return await ctx.db
|
|
67
|
+
* .query('tasks')
|
|
68
|
+
* .filter((q) => q.neq(q.field('deleted'), true))
|
|
69
|
+
* .collect();
|
|
70
|
+
* },
|
|
71
|
+
* });
|
|
72
|
+
*
|
|
73
|
+
* // In your route loader
|
|
74
|
+
* import { ConvexHttpClient } from 'convex/browser';
|
|
75
|
+
* import { api } from '../convex/_generated/api';
|
|
76
|
+
*
|
|
77
|
+
* const httpClient = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
|
|
78
|
+
* const tasks = await httpClient.query(api.tasks.list);
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export async function loadCollection<TItem extends { id: string }>(
|
|
82
|
+
httpClient: ConvexHttpClient,
|
|
83
|
+
config: LoadCollectionConfig
|
|
84
|
+
): Promise<ReadonlyArray<TItem>> {
|
|
85
|
+
// NOTE: This implementation is limited because stream only returns CRDT bytes,
|
|
86
|
+
// not materialized documents. The code below attempts to construct items but
|
|
87
|
+
// `change.document` does not exist in the actual stream response.
|
|
88
|
+
//
|
|
89
|
+
// For production use, create a dedicated query that reads from your main table.
|
|
90
|
+
|
|
91
|
+
const result = await httpClient.query(config.api.stream as any, {
|
|
92
|
+
collectionName: config.collection,
|
|
93
|
+
checkpoint: { lastModified: 0 },
|
|
94
|
+
limit: config.limit ?? 100,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const items: TItem[] = [];
|
|
98
|
+
for (const change of result.changes) {
|
|
99
|
+
// FIXME: change.document doesn't exist - stream only returns crdtBytes
|
|
100
|
+
// This code is here for backwards compatibility but won't work correctly
|
|
101
|
+
const item = { id: change.documentId, ...change.document } as TItem;
|
|
102
|
+
items.push(item);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return items;
|
|
106
|
+
}
|