@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.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/LICENSE.package +201 -0
  3. package/README.md +871 -0
  4. package/dist/client/collection.d.ts +94 -0
  5. package/dist/client/index.d.ts +18 -0
  6. package/dist/client/logger.d.ts +3 -0
  7. package/dist/client/storage.d.ts +143 -0
  8. package/dist/component/_generated/api.js +5 -0
  9. package/dist/component/_generated/server.js +9 -0
  10. package/dist/component/convex.config.d.ts +2 -0
  11. package/dist/component/convex.config.js +3 -0
  12. package/dist/component/public.d.ts +99 -0
  13. package/dist/component/public.js +135 -0
  14. package/dist/component/schema.d.ts +22 -0
  15. package/dist/component/schema.js +22 -0
  16. package/dist/index.js +375 -0
  17. package/dist/server/index.d.ts +17 -0
  18. package/dist/server/replication.d.ts +122 -0
  19. package/dist/server/schema.d.ts +73 -0
  20. package/dist/server/ssr.d.ts +79 -0
  21. package/dist/server.js +96 -0
  22. package/dist/ssr.js +19 -0
  23. package/package.json +108 -0
  24. package/src/client/collection.ts +550 -0
  25. package/src/client/index.ts +31 -0
  26. package/src/client/logger.ts +31 -0
  27. package/src/client/storage.ts +206 -0
  28. package/src/component/_generated/api.d.ts +95 -0
  29. package/src/component/_generated/api.js +23 -0
  30. package/src/component/_generated/dataModel.d.ts +60 -0
  31. package/src/component/_generated/server.d.ts +149 -0
  32. package/src/component/_generated/server.js +90 -0
  33. package/src/component/convex.config.ts +3 -0
  34. package/src/component/public.ts +212 -0
  35. package/src/component/schema.ts +16 -0
  36. package/src/server/index.ts +26 -0
  37. package/src/server/replication.ts +244 -0
  38. package/src/server/schema.ts +97 -0
  39. 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
+ }