@trestleinc/replicate 0.1.0 → 1.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/README.md +356 -420
- package/dist/client/collection.d.ts +78 -76
- package/dist/client/errors.d.ts +59 -0
- package/dist/client/index.d.ts +22 -18
- package/dist/client/logger.d.ts +0 -1
- package/dist/client/merge.d.ts +77 -0
- package/dist/client/persistence/adapters/index.d.ts +8 -0
- package/dist/client/persistence/adapters/opsqlite.d.ts +46 -0
- package/dist/client/persistence/adapters/sqljs.d.ts +83 -0
- package/dist/client/persistence/index.d.ts +49 -0
- package/dist/client/persistence/indexeddb.d.ts +17 -0
- package/dist/client/persistence/memory.d.ts +16 -0
- package/dist/client/persistence/sqlite-browser.d.ts +51 -0
- package/dist/client/persistence/sqlite-level.d.ts +63 -0
- package/dist/client/persistence/sqlite-rn.d.ts +36 -0
- package/dist/client/persistence/sqlite.d.ts +47 -0
- package/dist/client/persistence/types.d.ts +42 -0
- package/dist/client/prose.d.ts +56 -0
- package/dist/client/replicate.d.ts +40 -0
- package/dist/client/services/checkpoint.d.ts +18 -0
- package/dist/client/services/reconciliation.d.ts +24 -0
- package/dist/component/_generated/api.d.ts +35 -0
- package/dist/component/_generated/api.js +3 -3
- package/dist/component/_generated/component.d.ts +89 -0
- package/dist/component/_generated/component.js +0 -0
- package/dist/component/_generated/dataModel.d.ts +45 -0
- package/dist/component/_generated/dataModel.js +0 -0
- package/{src → dist}/component/_generated/server.d.ts +9 -38
- package/dist/component/convex.config.d.ts +2 -2
- package/dist/component/convex.config.js +2 -1
- package/dist/component/logger.d.ts +8 -0
- package/dist/component/logger.js +30 -0
- package/dist/component/public.d.ts +36 -61
- package/dist/component/public.js +232 -58
- package/dist/component/schema.d.ts +32 -8
- package/dist/component/schema.js +19 -6
- package/dist/index.js +1553 -308
- package/dist/server/builder.d.ts +94 -0
- package/dist/server/index.d.ts +14 -17
- package/dist/server/schema.d.ts +17 -63
- package/dist/server/storage.d.ts +80 -0
- package/dist/server.js +268 -83
- package/dist/shared/index.d.ts +5 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +50 -0
- package/dist/shared/types.js +6 -0
- package/dist/shared.js +6 -0
- package/package.json +59 -49
- package/src/client/collection.ts +877 -450
- package/src/client/errors.ts +45 -0
- package/src/client/index.ts +52 -26
- package/src/client/logger.ts +2 -28
- package/src/client/merge.ts +374 -0
- package/src/client/persistence/adapters/index.ts +8 -0
- package/src/client/persistence/adapters/opsqlite.ts +54 -0
- package/src/client/persistence/adapters/sqljs.ts +128 -0
- package/src/client/persistence/index.ts +54 -0
- package/src/client/persistence/indexeddb.ts +110 -0
- package/src/client/persistence/memory.ts +61 -0
- package/src/client/persistence/sqlite-browser.ts +107 -0
- package/src/client/persistence/sqlite-level.ts +407 -0
- package/src/client/persistence/sqlite-rn.ts +44 -0
- package/src/client/persistence/sqlite.ts +161 -0
- package/src/client/persistence/types.ts +49 -0
- package/src/client/prose.ts +369 -0
- package/src/client/replicate.ts +80 -0
- package/src/client/services/checkpoint.ts +86 -0
- package/src/client/services/reconciliation.ts +108 -0
- package/src/component/_generated/api.ts +52 -0
- package/src/component/_generated/component.ts +103 -0
- package/src/component/_generated/{dataModel.d.ts → dataModel.ts} +1 -1
- package/src/component/_generated/server.ts +161 -0
- package/src/component/convex.config.ts +3 -1
- package/src/component/logger.ts +36 -0
- package/src/component/public.ts +364 -111
- package/src/component/schema.ts +18 -5
- package/src/env.d.ts +31 -0
- package/src/server/builder.ts +85 -0
- package/src/server/index.ts +9 -24
- package/src/server/schema.ts +20 -76
- package/src/server/storage.ts +313 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/types.ts +52 -0
- package/LICENSE.package +0 -201
- package/dist/client/storage.d.ts +0 -143
- package/dist/server/replication.d.ts +0 -122
- package/dist/server/ssr.d.ts +0 -79
- package/dist/ssr.js +0 -19
- package/src/client/storage.ts +0 -206
- package/src/component/_generated/api.d.ts +0 -95
- package/src/component/_generated/api.js +0 -23
- package/src/component/_generated/server.js +0 -90
- package/src/server/replication.ts +0 -244
- package/src/server/ssr.ts +0 -106
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { GenericMutationCtx, GenericQueryCtx, GenericDataModel } from 'convex/server';
|
|
2
|
+
import { Replicate } from '$/server/storage.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for replicate handlers (without component - used with factory pattern).
|
|
6
|
+
*/
|
|
7
|
+
export interface ReplicateConfig<T extends object> {
|
|
8
|
+
collection: string;
|
|
9
|
+
/** Size threshold for auto-compaction (default: 5MB). Set to 0 to disable. */
|
|
10
|
+
compaction?: { threshold?: number };
|
|
11
|
+
hooks?: {
|
|
12
|
+
evalRead?: (ctx: GenericQueryCtx<GenericDataModel>, collection: string) => void | Promise<void>;
|
|
13
|
+
evalWrite?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
14
|
+
evalRemove?: (ctx: GenericMutationCtx<GenericDataModel>, docId: string) => void | Promise<void>;
|
|
15
|
+
onStream?: (ctx: GenericQueryCtx<GenericDataModel>, result: any) => void | Promise<void>;
|
|
16
|
+
onInsert?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
17
|
+
onUpdate?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
18
|
+
onRemove?: (ctx: GenericMutationCtx<GenericDataModel>, docId: string) => void | Promise<void>;
|
|
19
|
+
transform?: (docs: T[]) => T[] | Promise<T[]>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a replicate function bound to your component. Call this once in your
|
|
25
|
+
* convex/replicate.ts file, then use the returned function for all collections.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* // convex/replicate.ts (create once)
|
|
30
|
+
* import { replicate } from '@trestleinc/replicate/server';
|
|
31
|
+
* import { components } from './_generated/api';
|
|
32
|
+
*
|
|
33
|
+
* export const tasks = replicate(components.replicate)<Task>({ collection: 'tasks' });
|
|
34
|
+
*
|
|
35
|
+
* // Or bind once and reuse:
|
|
36
|
+
* const r = replicate(components.replicate);
|
|
37
|
+
* export const tasks = r<Task>({ collection: 'tasks' });
|
|
38
|
+
* export const notebooks = r<Notebook>({ collection: 'notebooks' });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function replicate(component: any) {
|
|
42
|
+
return function boundReplicate<T extends object>(config: ReplicateConfig<T>) {
|
|
43
|
+
return replicateInternal<T>(component, config);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Internal implementation for replicate.
|
|
49
|
+
*/
|
|
50
|
+
function replicateInternal<T extends object>(component: any, config: ReplicateConfig<T>) {
|
|
51
|
+
const storage = new Replicate<T>(component, config.collection, {
|
|
52
|
+
threshold: config.compaction?.threshold,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
stream: storage.createStreamQuery({
|
|
57
|
+
evalRead: config.hooks?.evalRead,
|
|
58
|
+
onStream: config.hooks?.onStream,
|
|
59
|
+
}),
|
|
60
|
+
|
|
61
|
+
material: storage.createSSRQuery({
|
|
62
|
+
evalRead: config.hooks?.evalRead,
|
|
63
|
+
transform: config.hooks?.transform,
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
recovery: storage.createRecoveryQuery({
|
|
67
|
+
evalRead: config.hooks?.evalRead,
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
insert: storage.createInsertMutation({
|
|
71
|
+
evalWrite: config.hooks?.evalWrite,
|
|
72
|
+
onInsert: config.hooks?.onInsert,
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
update: storage.createUpdateMutation({
|
|
76
|
+
evalWrite: config.hooks?.evalWrite,
|
|
77
|
+
onUpdate: config.hooks?.onUpdate,
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
remove: storage.createRemoveMutation({
|
|
81
|
+
evalRemove: config.hooks?.evalRemove,
|
|
82
|
+
onRemove: config.hooks?.onRemove,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
*/
|
|
1
|
+
export { replicate } from '$/server/builder.js';
|
|
2
|
+
export type { ReplicateConfig } from '$/server/builder.js';
|
|
16
3
|
|
|
17
|
-
|
|
18
|
-
export {
|
|
19
|
-
insertDocumentHelper,
|
|
20
|
-
updateDocumentHelper,
|
|
21
|
-
deleteDocumentHelper,
|
|
22
|
-
streamHelper,
|
|
23
|
-
} from './replication.js';
|
|
4
|
+
import { table, prose } from '$/server/schema.js';
|
|
24
5
|
|
|
25
|
-
|
|
26
|
-
|
|
6
|
+
export const schema = {
|
|
7
|
+
table,
|
|
8
|
+
prose,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type { ReplicationFields } from '$/server/schema.js';
|
package/src/server/schema.ts
CHANGED
|
@@ -1,97 +1,41 @@
|
|
|
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
1
|
import { defineTable } from 'convex/server';
|
|
28
2
|
import { v } from 'convex/values';
|
|
29
3
|
|
|
30
|
-
/**
|
|
31
|
-
* Internal replication metadata fields added to every replicated table.
|
|
32
|
-
* These are managed automatically by the replication layer.
|
|
33
|
-
*/
|
|
4
|
+
/** Fields automatically added to replicated tables */
|
|
34
5
|
export type ReplicationFields = {
|
|
35
|
-
/** Version number for conflict resolution */
|
|
36
|
-
version: number;
|
|
37
|
-
/** Last modification timestamp (Unix ms) */
|
|
38
6
|
timestamp: number;
|
|
39
7
|
};
|
|
40
8
|
|
|
9
|
+
export const prose = () =>
|
|
10
|
+
v.object({
|
|
11
|
+
type: v.literal('doc'),
|
|
12
|
+
content: v.optional(v.array(v.any())),
|
|
13
|
+
});
|
|
14
|
+
|
|
41
15
|
/**
|
|
42
|
-
*
|
|
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
|
|
16
|
+
* Define a table with automatic timestamp field for replication.
|
|
17
|
+
* All replicated tables must have an `id` field and define a `by_doc_id` index.
|
|
57
18
|
*
|
|
58
19
|
* @example
|
|
59
20
|
* ```typescript
|
|
60
|
-
* //
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
* )
|
|
21
|
+
* // convex/schema.ts
|
|
22
|
+
* export default defineSchema({
|
|
23
|
+
* tasks: table(
|
|
24
|
+
* { id: v.string(), text: v.string(), isCompleted: v.boolean() },
|
|
25
|
+
* (t) => t.index('by_doc_id', ['id']).index('by_completed', ['isCompleted'])
|
|
26
|
+
* ),
|
|
27
|
+
* });
|
|
76
28
|
* ```
|
|
77
29
|
*/
|
|
78
|
-
export function
|
|
79
|
-
|
|
80
|
-
applyIndexes?: (table: any) => any
|
|
81
|
-
): any {
|
|
82
|
-
// Create table with user fields + replication metadata
|
|
83
|
-
const tableWithMetadata = defineTable({
|
|
30
|
+
export function table(userFields: Record<string, any>, applyIndexes?: (table: any) => any): any {
|
|
31
|
+
const tbl = defineTable({
|
|
84
32
|
...userFields,
|
|
85
|
-
|
|
86
|
-
// Injected replication fields (hidden from user's mental model)
|
|
87
|
-
version: v.number(),
|
|
88
33
|
timestamp: v.number(),
|
|
89
34
|
});
|
|
90
35
|
|
|
91
|
-
// Apply user-defined indexes if provided
|
|
92
36
|
if (applyIndexes) {
|
|
93
|
-
return applyIndexes(
|
|
37
|
+
return applyIndexes(tbl);
|
|
94
38
|
}
|
|
95
39
|
|
|
96
|
-
return
|
|
40
|
+
return tbl;
|
|
97
41
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { v } from 'convex/values';
|
|
2
|
+
import type { GenericMutationCtx, GenericQueryCtx, GenericDataModel } from 'convex/server';
|
|
3
|
+
import { queryGeneric, mutationGeneric } from 'convex/server';
|
|
4
|
+
|
|
5
|
+
export class Replicate<T extends object> {
|
|
6
|
+
constructor(
|
|
7
|
+
public component: any,
|
|
8
|
+
public collectionName: string,
|
|
9
|
+
private options?: { threshold?: number }
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
createStreamQuery(opts?: {
|
|
13
|
+
evalRead?: (ctx: GenericQueryCtx<GenericDataModel>, collection: string) => void | Promise<void>;
|
|
14
|
+
onStream?: (ctx: GenericQueryCtx<GenericDataModel>, result: any) => void | Promise<void>;
|
|
15
|
+
}) {
|
|
16
|
+
const component = this.component;
|
|
17
|
+
const collection = this.collectionName;
|
|
18
|
+
|
|
19
|
+
return queryGeneric({
|
|
20
|
+
args: {
|
|
21
|
+
checkpoint: v.object({ lastModified: v.number() }),
|
|
22
|
+
limit: v.optional(v.number()),
|
|
23
|
+
vector: v.optional(v.bytes()),
|
|
24
|
+
},
|
|
25
|
+
returns: v.object({
|
|
26
|
+
changes: v.array(
|
|
27
|
+
v.object({
|
|
28
|
+
documentId: v.optional(v.string()),
|
|
29
|
+
crdtBytes: v.bytes(),
|
|
30
|
+
version: v.number(),
|
|
31
|
+
timestamp: v.number(),
|
|
32
|
+
operationType: v.string(),
|
|
33
|
+
})
|
|
34
|
+
),
|
|
35
|
+
checkpoint: v.object({ lastModified: v.number() }),
|
|
36
|
+
hasMore: v.boolean(),
|
|
37
|
+
}),
|
|
38
|
+
handler: async (ctx, args) => {
|
|
39
|
+
if (opts?.evalRead) {
|
|
40
|
+
await opts.evalRead(ctx, collection);
|
|
41
|
+
}
|
|
42
|
+
const result = await ctx.runQuery(component.public.stream, {
|
|
43
|
+
collection,
|
|
44
|
+
checkpoint: args.checkpoint,
|
|
45
|
+
limit: args.limit,
|
|
46
|
+
vector: args.vector,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (opts?.onStream) {
|
|
50
|
+
await opts.onStream(ctx, result);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
createSSRQuery(opts?: {
|
|
59
|
+
evalRead?: (ctx: GenericQueryCtx<GenericDataModel>, collection: string) => void | Promise<void>;
|
|
60
|
+
transform?: (docs: T[]) => T[] | Promise<T[]>;
|
|
61
|
+
includeCRDTState?: boolean;
|
|
62
|
+
}) {
|
|
63
|
+
const collection = this.collectionName;
|
|
64
|
+
const component = this.component;
|
|
65
|
+
|
|
66
|
+
return queryGeneric({
|
|
67
|
+
args: {},
|
|
68
|
+
returns: v.object({
|
|
69
|
+
documents: v.any(),
|
|
70
|
+
checkpoint: v.optional(v.object({ lastModified: v.number() })),
|
|
71
|
+
count: v.number(),
|
|
72
|
+
crdtBytes: v.optional(v.bytes()),
|
|
73
|
+
}),
|
|
74
|
+
handler: async (ctx) => {
|
|
75
|
+
if (opts?.evalRead) {
|
|
76
|
+
await opts.evalRead(ctx, collection);
|
|
77
|
+
}
|
|
78
|
+
let docs = (await ctx.db.query(collection).collect()) as T[];
|
|
79
|
+
if (opts?.transform) {
|
|
80
|
+
docs = await opts.transform(docs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const latestTimestamp =
|
|
84
|
+
docs.length > 0 ? Math.max(...docs.map((doc: any) => doc.timestamp || 0)) : 0;
|
|
85
|
+
|
|
86
|
+
const response: {
|
|
87
|
+
documents: T[];
|
|
88
|
+
checkpoint?: { lastModified: number };
|
|
89
|
+
count: number;
|
|
90
|
+
crdtBytes?: ArrayBuffer;
|
|
91
|
+
} = {
|
|
92
|
+
documents: docs,
|
|
93
|
+
checkpoint: latestTimestamp > 0 ? { lastModified: latestTimestamp } : undefined,
|
|
94
|
+
count: docs.length,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (opts?.includeCRDTState) {
|
|
98
|
+
const crdtState = await ctx.runQuery(component.public.getInitialState, {
|
|
99
|
+
collection,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (crdtState) {
|
|
103
|
+
response.crdtBytes = crdtState.crdtBytes;
|
|
104
|
+
response.checkpoint = crdtState.checkpoint;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return response;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
createInsertMutation(opts?: {
|
|
113
|
+
evalWrite?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
114
|
+
onInsert?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
115
|
+
}) {
|
|
116
|
+
const component = this.component;
|
|
117
|
+
const collection = this.collectionName;
|
|
118
|
+
const threshold = this.options?.threshold;
|
|
119
|
+
|
|
120
|
+
return mutationGeneric({
|
|
121
|
+
args: {
|
|
122
|
+
documentId: v.string(),
|
|
123
|
+
crdtBytes: v.bytes(),
|
|
124
|
+
materializedDoc: v.any(),
|
|
125
|
+
},
|
|
126
|
+
returns: v.object({
|
|
127
|
+
success: v.boolean(),
|
|
128
|
+
metadata: v.any(),
|
|
129
|
+
}),
|
|
130
|
+
handler: async (ctx, args) => {
|
|
131
|
+
const doc = args.materializedDoc as T;
|
|
132
|
+
|
|
133
|
+
if (opts?.evalWrite) {
|
|
134
|
+
await opts.evalWrite(ctx, doc);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const version = Date.now();
|
|
138
|
+
await ctx.runMutation(component.public.insertDocument, {
|
|
139
|
+
collection,
|
|
140
|
+
documentId: args.documentId,
|
|
141
|
+
crdtBytes: args.crdtBytes,
|
|
142
|
+
version,
|
|
143
|
+
threshold,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ctx.db.insert(collection, {
|
|
147
|
+
id: args.documentId,
|
|
148
|
+
...(args.materializedDoc as object),
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (opts?.onInsert) {
|
|
153
|
+
await opts.onInsert(ctx, doc);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
success: true,
|
|
158
|
+
metadata: {
|
|
159
|
+
documentId: args.documentId,
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
collection,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
createUpdateMutation(opts?: {
|
|
169
|
+
evalWrite?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
170
|
+
onUpdate?: (ctx: GenericMutationCtx<GenericDataModel>, doc: T) => void | Promise<void>;
|
|
171
|
+
}) {
|
|
172
|
+
const component = this.component;
|
|
173
|
+
const collection = this.collectionName;
|
|
174
|
+
const threshold = this.options?.threshold;
|
|
175
|
+
|
|
176
|
+
return mutationGeneric({
|
|
177
|
+
args: {
|
|
178
|
+
documentId: v.string(),
|
|
179
|
+
crdtBytes: v.bytes(),
|
|
180
|
+
materializedDoc: v.any(),
|
|
181
|
+
},
|
|
182
|
+
returns: v.object({
|
|
183
|
+
success: v.boolean(),
|
|
184
|
+
metadata: v.any(),
|
|
185
|
+
}),
|
|
186
|
+
handler: async (ctx, args) => {
|
|
187
|
+
const doc = args.materializedDoc as T;
|
|
188
|
+
|
|
189
|
+
if (opts?.evalWrite) {
|
|
190
|
+
await opts.evalWrite(ctx, doc);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const version = Date.now();
|
|
194
|
+
await ctx.runMutation(component.public.updateDocument, {
|
|
195
|
+
collection,
|
|
196
|
+
documentId: args.documentId,
|
|
197
|
+
crdtBytes: args.crdtBytes,
|
|
198
|
+
version,
|
|
199
|
+
threshold,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const existing = await ctx.db
|
|
203
|
+
.query(collection)
|
|
204
|
+
.withIndex('by_doc_id', (q) => q.eq('id', args.documentId))
|
|
205
|
+
.first();
|
|
206
|
+
|
|
207
|
+
if (existing) {
|
|
208
|
+
await ctx.db.patch(collection, existing._id, {
|
|
209
|
+
...(args.materializedDoc as object),
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (opts?.onUpdate) {
|
|
215
|
+
await opts.onUpdate(ctx, doc);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
success: true,
|
|
220
|
+
metadata: {
|
|
221
|
+
documentId: args.documentId,
|
|
222
|
+
timestamp: Date.now(),
|
|
223
|
+
collection,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
createRemoveMutation(opts?: {
|
|
231
|
+
evalRemove?: (ctx: GenericMutationCtx<GenericDataModel>, docId: string) => void | Promise<void>;
|
|
232
|
+
onRemove?: (ctx: GenericMutationCtx<GenericDataModel>, docId: string) => void | Promise<void>;
|
|
233
|
+
}) {
|
|
234
|
+
const component = this.component;
|
|
235
|
+
const collection = this.collectionName;
|
|
236
|
+
const threshold = this.options?.threshold;
|
|
237
|
+
|
|
238
|
+
return mutationGeneric({
|
|
239
|
+
args: {
|
|
240
|
+
documentId: v.string(),
|
|
241
|
+
crdtBytes: v.bytes(),
|
|
242
|
+
},
|
|
243
|
+
returns: v.object({
|
|
244
|
+
success: v.boolean(),
|
|
245
|
+
metadata: v.any(),
|
|
246
|
+
}),
|
|
247
|
+
handler: async (ctx, args) => {
|
|
248
|
+
const documentId = args.documentId as string;
|
|
249
|
+
if (opts?.evalRemove) {
|
|
250
|
+
await opts.evalRemove(ctx, documentId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const version = Date.now();
|
|
254
|
+
await ctx.runMutation(component.public.deleteDocument, {
|
|
255
|
+
collection,
|
|
256
|
+
documentId: documentId,
|
|
257
|
+
crdtBytes: args.crdtBytes,
|
|
258
|
+
version,
|
|
259
|
+
threshold,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const existing = await ctx.db
|
|
263
|
+
.query(collection)
|
|
264
|
+
.withIndex('by_doc_id', (q) => q.eq('id', documentId))
|
|
265
|
+
.first();
|
|
266
|
+
|
|
267
|
+
if (existing) {
|
|
268
|
+
await ctx.db.delete(collection, existing._id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (opts?.onRemove) {
|
|
272
|
+
await opts.onRemove(ctx, documentId);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
success: true,
|
|
277
|
+
metadata: {
|
|
278
|
+
documentId: documentId,
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
collection,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
createRecoveryQuery(opts?: {
|
|
288
|
+
evalRead?: (ctx: GenericQueryCtx<GenericDataModel>, collection: string) => void | Promise<void>;
|
|
289
|
+
}) {
|
|
290
|
+
const component = this.component;
|
|
291
|
+
const collection = this.collectionName;
|
|
292
|
+
|
|
293
|
+
return queryGeneric({
|
|
294
|
+
args: {
|
|
295
|
+
clientStateVector: v.bytes(),
|
|
296
|
+
},
|
|
297
|
+
returns: v.object({
|
|
298
|
+
diff: v.optional(v.bytes()),
|
|
299
|
+
serverStateVector: v.bytes(),
|
|
300
|
+
}),
|
|
301
|
+
handler: async (ctx, args) => {
|
|
302
|
+
if (opts?.evalRead) {
|
|
303
|
+
await opts.evalRead(ctx, collection);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return await ctx.runQuery(component.public.recovery, {
|
|
307
|
+
collection,
|
|
308
|
+
clientStateVector: args.clientStateVector,
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for @trestleinc/replicate
|
|
3
|
+
*
|
|
4
|
+
* These types are used across client, server, and component code.
|
|
5
|
+
* They are safe to import in any environment (browser, Node.js, Convex).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Marker used during insert/update to signal a fragment field */
|
|
9
|
+
export interface FragmentValue {
|
|
10
|
+
__xmlFragment: true;
|
|
11
|
+
content?: XmlFragmentJSON;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** ProseMirror-compatible JSON for XmlFragment serialization */
|
|
15
|
+
export interface XmlFragmentJSON {
|
|
16
|
+
type: "doc";
|
|
17
|
+
content?: XmlNodeJSON[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ProseMirror node structure */
|
|
21
|
+
export interface XmlNodeJSON {
|
|
22
|
+
type: string;
|
|
23
|
+
attrs?: Record<string, unknown>;
|
|
24
|
+
content?: XmlNodeJSON[];
|
|
25
|
+
text?: string;
|
|
26
|
+
marks?: { type: string; attrs?: Record<string, unknown> }[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Operation type for streaming changes */
|
|
30
|
+
export enum OperationType {
|
|
31
|
+
Delta = "delta",
|
|
32
|
+
Snapshot = "snapshot",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract field names from T where the value type is XmlFragmentJSON.
|
|
37
|
+
* Used for type-safe prose field configuration.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* interface Notebook {
|
|
42
|
+
* id: string;
|
|
43
|
+
* title: string;
|
|
44
|
+
* content: XmlFragmentJSON;
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* type Fields = ProseFields<Notebook>; // 'content'
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export type ProseFields<T> = {
|
|
51
|
+
[K in keyof T]: T[K] extends XmlFragmentJSON ? K : never;
|
|
52
|
+
}[keyof T];
|