@syncular/server-plugin-yjs 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/package.json +58 -0
- package/src/index.test.ts +249 -0
- package/src/index.ts +702 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @syncular/server-plugin-crdt-yjs
|
|
2
|
+
|
|
3
|
+
Yjs-first server integration helpers for Syncular.
|
|
4
|
+
|
|
5
|
+
Implemented:
|
|
6
|
+
- Rule-based field mapping: `{ table, field, stateColumn }`.
|
|
7
|
+
- Kind-aware materialization: `text`, `xml-fragment`, `prosemirror`.
|
|
8
|
+
- Stable payload envelope key: `__yjs`.
|
|
9
|
+
- `createYjsServerModule()` with helpers:
|
|
10
|
+
- `applyPayload(...)`: merge incoming Yjs envelopes into materialized payload + state column.
|
|
11
|
+
- `materializeRow(...)`: derive projection field values from stored Yjs state.
|
|
12
|
+
- `createYjsServerPushPlugin()`:
|
|
13
|
+
- `beforeApplyOperation`: CRDT envelope -> payload/state transform.
|
|
14
|
+
- `afterApplyOperation`: materialize emitted rows + conflict rows.
|
|
15
|
+
- Utility exports:
|
|
16
|
+
- `buildYjsTextUpdate`
|
|
17
|
+
- `applyYjsTextUpdates`
|
|
18
|
+
|
|
19
|
+
## Example
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createYjsServerModule } from '@syncular/server-plugin-crdt-yjs';
|
|
23
|
+
|
|
24
|
+
const yjs = createYjsServerModule({
|
|
25
|
+
rules: [
|
|
26
|
+
{
|
|
27
|
+
table: 'tasks',
|
|
28
|
+
field: 'content',
|
|
29
|
+
stateColumn: 'content_yjs_state',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const nextPayload = yjs.applyPayload({
|
|
35
|
+
table: 'tasks',
|
|
36
|
+
rowId: 'task-1',
|
|
37
|
+
payload: incomingPayload,
|
|
38
|
+
existingRow: currentRow,
|
|
39
|
+
});
|
|
40
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syncular/server-plugin-yjs",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Yjs CRDT server integration primitives for Syncular handlers",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Benjamin Kniffler",
|
|
7
|
+
"homepage": "https://syncular.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
+
"directory": "plugins/crdt-yjs/server"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/syncular/syncular/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"sync",
|
|
18
|
+
"offline-first",
|
|
19
|
+
"realtime",
|
|
20
|
+
"database",
|
|
21
|
+
"typescript",
|
|
22
|
+
"crdt",
|
|
23
|
+
"yjs"
|
|
24
|
+
],
|
|
25
|
+
"private": false,
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"browser": "./src/index.ts",
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "bun test --pass-with-no-tests",
|
|
42
|
+
"tsgo": "tsgo --noEmit",
|
|
43
|
+
"build": "tsgo",
|
|
44
|
+
"release": "bunx syncular-publish"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@syncular/config": "0.0.0"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"src"
|
|
52
|
+
],
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@syncular/server": "0.0.0",
|
|
55
|
+
"kysely": "^0.28.11",
|
|
56
|
+
"yjs": "^13.6.29"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import * as Y from 'yjs';
|
|
3
|
+
import {
|
|
4
|
+
buildYjsTextUpdate,
|
|
5
|
+
createYjsServerModule,
|
|
6
|
+
YJS_PAYLOAD_KEY,
|
|
7
|
+
type YjsServerUpdateEnvelope,
|
|
8
|
+
} from './index';
|
|
9
|
+
|
|
10
|
+
function createUpdate(
|
|
11
|
+
text: string,
|
|
12
|
+
previousStateBase64?: string
|
|
13
|
+
): Promise<{ update: YjsServerUpdateEnvelope; state: string }> {
|
|
14
|
+
const built = buildYjsTextUpdate({
|
|
15
|
+
previousStateBase64,
|
|
16
|
+
nextText: text,
|
|
17
|
+
containerKey: 'content',
|
|
18
|
+
});
|
|
19
|
+
return built.then((value) => ({
|
|
20
|
+
update: value.update,
|
|
21
|
+
state: value.nextStateBase64,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
26
|
+
return Buffer.from(bytes).toString('base64');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createXmlInsert(
|
|
30
|
+
text: string,
|
|
31
|
+
previousStateBase64?: string
|
|
32
|
+
): { update: YjsServerUpdateEnvelope; state: string } {
|
|
33
|
+
const doc = new Y.Doc();
|
|
34
|
+
if (previousStateBase64) {
|
|
35
|
+
Y.applyUpdate(
|
|
36
|
+
doc,
|
|
37
|
+
new Uint8Array(Buffer.from(previousStateBase64, 'base64'))
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const from = Y.encodeStateVector(doc);
|
|
42
|
+
const fragment = doc.getXmlFragment('content');
|
|
43
|
+
doc.transact(() => {
|
|
44
|
+
const paragraph = new Y.XmlElement('p');
|
|
45
|
+
const xmlText = new Y.XmlText();
|
|
46
|
+
xmlText.insert(0, text);
|
|
47
|
+
paragraph.insert(0, [xmlText]);
|
|
48
|
+
fragment.insert(fragment.length, [paragraph]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const update = bytesToBase64(Y.encodeStateAsUpdate(doc, from));
|
|
52
|
+
const state = bytesToBase64(Y.encodeStateAsUpdate(doc));
|
|
53
|
+
doc.destroy();
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
update: {
|
|
57
|
+
updateId: `xml-${text}`,
|
|
58
|
+
updateBase64: update,
|
|
59
|
+
},
|
|
60
|
+
state,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('@syncular/server-plugin-crdt-yjs', () => {
|
|
65
|
+
it('applies Yjs envelopes against existing row state and strips envelope key', async () => {
|
|
66
|
+
const module = createYjsServerModule({
|
|
67
|
+
rules: [
|
|
68
|
+
{
|
|
69
|
+
table: 'tasks',
|
|
70
|
+
field: 'content',
|
|
71
|
+
stateColumn: 'content_yjs_state',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const base = await createUpdate('Hello');
|
|
77
|
+
const nextUpdate = await createUpdate('Hello world', base.state);
|
|
78
|
+
|
|
79
|
+
const payload = {
|
|
80
|
+
title: 'Task title',
|
|
81
|
+
[YJS_PAYLOAD_KEY]: {
|
|
82
|
+
content: nextUpdate.update,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const nextPayload = await module.applyPayload({
|
|
87
|
+
table: 'tasks',
|
|
88
|
+
rowId: 'task-1',
|
|
89
|
+
payload,
|
|
90
|
+
existingRow: {
|
|
91
|
+
id: 'task-1',
|
|
92
|
+
content: 'Hello',
|
|
93
|
+
content_yjs_state: base.state,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(nextPayload.title).toBe('Task title');
|
|
98
|
+
expect(nextPayload.content).toBe('Hello world');
|
|
99
|
+
expect(typeof nextPayload.content_yjs_state).toBe('string');
|
|
100
|
+
expect(YJS_PAYLOAD_KEY in nextPayload).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('materializes outbound rows from Yjs state columns', async () => {
|
|
104
|
+
const module = createYjsServerModule({
|
|
105
|
+
rules: [
|
|
106
|
+
{
|
|
107
|
+
table: 'tasks',
|
|
108
|
+
field: 'content',
|
|
109
|
+
stateColumn: 'content_yjs_state',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const base = await createUpdate('Derived text');
|
|
115
|
+
const row = await module.materializeRow({
|
|
116
|
+
table: 'tasks',
|
|
117
|
+
row: {
|
|
118
|
+
id: 'task-1',
|
|
119
|
+
content: 'stale',
|
|
120
|
+
content_yjs_state: base.state,
|
|
121
|
+
[YJS_PAYLOAD_KEY]: {
|
|
122
|
+
should_be_removed: true,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(row.content).toBe('Derived text');
|
|
128
|
+
expect(YJS_PAYLOAD_KEY in row).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('materializes rows when state is stored as UTF-8 bytes of a base64 string', async () => {
|
|
132
|
+
const module = createYjsServerModule({
|
|
133
|
+
rules: [
|
|
134
|
+
{
|
|
135
|
+
table: 'tasks',
|
|
136
|
+
field: 'content',
|
|
137
|
+
stateColumn: 'content_yjs_state',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const base = await createUpdate('From bytes');
|
|
143
|
+
const row = await module.materializeRow({
|
|
144
|
+
table: 'tasks',
|
|
145
|
+
row: {
|
|
146
|
+
id: 'task-1',
|
|
147
|
+
content: 'stale',
|
|
148
|
+
content_yjs_state: new TextEncoder().encode(base.state),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(row.content).toBe('From bytes');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('throws in strict mode when envelope references unknown fields', async () => {
|
|
156
|
+
const module = createYjsServerModule({
|
|
157
|
+
rules: [
|
|
158
|
+
{
|
|
159
|
+
table: 'tasks',
|
|
160
|
+
field: 'content',
|
|
161
|
+
stateColumn: 'content_yjs_state',
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
strict: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const update = await createUpdate('Hello');
|
|
168
|
+
await expect(
|
|
169
|
+
module.applyPayload({
|
|
170
|
+
table: 'tasks',
|
|
171
|
+
rowId: 'task-1',
|
|
172
|
+
payload: {
|
|
173
|
+
[YJS_PAYLOAD_KEY]: {
|
|
174
|
+
unknown_field: update.update,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
).rejects.toThrow('No Yjs rule found for envelope field "unknown_field"');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('prefers existing row state over stale payload state when applying envelopes', async () => {
|
|
182
|
+
const module = createYjsServerModule({
|
|
183
|
+
rules: [
|
|
184
|
+
{
|
|
185
|
+
table: 'tasks',
|
|
186
|
+
field: 'content',
|
|
187
|
+
stateColumn: 'content_yjs_state',
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const base = await createUpdate('Start');
|
|
193
|
+
const otherClient = await createUpdate(
|
|
194
|
+
'Start from other client',
|
|
195
|
+
base.state
|
|
196
|
+
);
|
|
197
|
+
const localUpdate = await createUpdate(
|
|
198
|
+
'Start from this client',
|
|
199
|
+
base.state
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const nextPayload = await module.applyPayload({
|
|
203
|
+
table: 'tasks',
|
|
204
|
+
rowId: 'task-1',
|
|
205
|
+
payload: {
|
|
206
|
+
content: 'Start from this client',
|
|
207
|
+
content_yjs_state: localUpdate.state,
|
|
208
|
+
[YJS_PAYLOAD_KEY]: {
|
|
209
|
+
content: localUpdate.update,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
existingRow: {
|
|
213
|
+
id: 'task-1',
|
|
214
|
+
content: 'Start from other client',
|
|
215
|
+
content_yjs_state: otherClient.state,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(nextPayload.content).toContain('from other client');
|
|
220
|
+
expect(nextPayload.content).toContain('from this client');
|
|
221
|
+
expect(nextPayload.content_yjs_state).not.toBe(localUpdate.state);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('materializes xml-fragment kind from Yjs state snapshots', async () => {
|
|
225
|
+
const module = createYjsServerModule({
|
|
226
|
+
rules: [
|
|
227
|
+
{
|
|
228
|
+
table: 'tasks',
|
|
229
|
+
field: 'content',
|
|
230
|
+
stateColumn: 'content_yjs_state',
|
|
231
|
+
kind: 'xml-fragment',
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const xml = createXmlInsert('Hello XML');
|
|
237
|
+
const row = await module.materializeRow({
|
|
238
|
+
table: 'tasks',
|
|
239
|
+
row: {
|
|
240
|
+
id: 'task-1',
|
|
241
|
+
content: 'stale',
|
|
242
|
+
content_yjs_state: xml.state,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(typeof row.content).toBe('string');
|
|
247
|
+
expect(String(row.content)).toContain('Hello XML');
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ApplyOperationResult,
|
|
3
|
+
ServerPushPluginPriority,
|
|
4
|
+
type SyncServerPushPlugin,
|
|
5
|
+
} from '@syncular/server';
|
|
6
|
+
import { sql } from 'kysely';
|
|
7
|
+
import * as Y from 'yjs';
|
|
8
|
+
|
|
9
|
+
export const YJS_PAYLOAD_KEY = '__yjs';
|
|
10
|
+
|
|
11
|
+
const BASE64_PATTERN =
|
|
12
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
13
|
+
|
|
14
|
+
export type YjsFieldKind = 'text' | 'xml-fragment' | 'prosemirror';
|
|
15
|
+
|
|
16
|
+
export interface YjsServerFieldRule {
|
|
17
|
+
table: string;
|
|
18
|
+
field: string;
|
|
19
|
+
/**
|
|
20
|
+
* Column that stores canonical serialized Yjs state.
|
|
21
|
+
* Example: "content_yjs_state"
|
|
22
|
+
*/
|
|
23
|
+
stateColumn: string;
|
|
24
|
+
/**
|
|
25
|
+
* Container key inside the Yjs document. Defaults to `field`.
|
|
26
|
+
*/
|
|
27
|
+
containerKey?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Snapshot row id column. Defaults to `id`.
|
|
30
|
+
*/
|
|
31
|
+
rowIdField?: string;
|
|
32
|
+
/**
|
|
33
|
+
* CRDT container type.
|
|
34
|
+
*/
|
|
35
|
+
kind?: YjsFieldKind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ResolvedYjsServerFieldRule extends YjsServerFieldRule {
|
|
39
|
+
containerKey: string;
|
|
40
|
+
rowIdField: string;
|
|
41
|
+
kind: YjsFieldKind;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface YjsServerUpdateEnvelope {
|
|
45
|
+
updateId: string;
|
|
46
|
+
updateBase64: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type YjsServerUpdateInput =
|
|
50
|
+
| YjsServerUpdateEnvelope
|
|
51
|
+
| readonly YjsServerUpdateEnvelope[];
|
|
52
|
+
|
|
53
|
+
export interface YjsServerPayloadEnvelope {
|
|
54
|
+
[field: string]: YjsServerUpdateInput;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BuildYjsTextUpdateArgs {
|
|
58
|
+
previousStateBase64?: string | Uint8Array | null;
|
|
59
|
+
nextText: string;
|
|
60
|
+
containerKey?: string;
|
|
61
|
+
updateId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface BuildYjsTextUpdateResult {
|
|
65
|
+
update: YjsServerUpdateEnvelope;
|
|
66
|
+
nextStateBase64: string;
|
|
67
|
+
nextText: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ApplyYjsTextUpdatesArgs {
|
|
71
|
+
previousStateBase64?: string | Uint8Array | null;
|
|
72
|
+
updates: readonly YjsServerUpdateEnvelope[];
|
|
73
|
+
containerKey?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ApplyYjsTextUpdatesResult {
|
|
77
|
+
nextStateBase64: string;
|
|
78
|
+
text: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CreateYjsServerModuleOptions {
|
|
82
|
+
name?: string;
|
|
83
|
+
rules: readonly YjsServerFieldRule[];
|
|
84
|
+
envelopeKey?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Throw when envelope payload references fields without matching rules.
|
|
87
|
+
* @default true
|
|
88
|
+
*/
|
|
89
|
+
strict?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Remove the Yjs envelope key from processed payload/rows.
|
|
92
|
+
* @default true
|
|
93
|
+
*/
|
|
94
|
+
stripEnvelope?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CreateYjsServerPushPluginOptions
|
|
98
|
+
extends CreateYjsServerModuleOptions {
|
|
99
|
+
priority?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type RuleIndex = Map<string, Map<string, ResolvedYjsServerFieldRule>>;
|
|
103
|
+
|
|
104
|
+
export interface YjsServerApplyPayloadArgs {
|
|
105
|
+
table: string;
|
|
106
|
+
rowId: string;
|
|
107
|
+
payload: Record<string, unknown>;
|
|
108
|
+
existingRow?: Record<string, unknown> | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface YjsServerMaterializeRowArgs {
|
|
112
|
+
table: string;
|
|
113
|
+
row: Record<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface YjsServerModule {
|
|
117
|
+
name: string;
|
|
118
|
+
rules: readonly YjsServerFieldRule[];
|
|
119
|
+
envelopeKey: string;
|
|
120
|
+
applyPayload(
|
|
121
|
+
args: YjsServerApplyPayloadArgs
|
|
122
|
+
): Promise<Record<string, unknown>>;
|
|
123
|
+
materializeRow(
|
|
124
|
+
args: YjsServerMaterializeRowArgs
|
|
125
|
+
): Promise<Record<string, unknown>>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
129
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readString(value: unknown): string | null {
|
|
133
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
137
|
+
if (typeof Buffer !== 'undefined') {
|
|
138
|
+
return Buffer.from(bytes).toString('base64');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let binary = '';
|
|
142
|
+
for (const byte of bytes) {
|
|
143
|
+
binary += String.fromCharCode(byte);
|
|
144
|
+
}
|
|
145
|
+
return btoa(binary);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tryReadBase64TextFromBytes(bytes: Uint8Array): string | null {
|
|
149
|
+
if (bytes.length === 0) return null;
|
|
150
|
+
try {
|
|
151
|
+
const decoded = new TextDecoder().decode(bytes).trim();
|
|
152
|
+
if (!decoded || !BASE64_PATTERN.test(decoded)) return null;
|
|
153
|
+
return decoded;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function base64ToBytes(base64: string): Uint8Array {
|
|
160
|
+
if (!BASE64_PATTERN.test(base64)) {
|
|
161
|
+
throw new Error('Invalid base64 string');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof Buffer !== 'undefined') {
|
|
165
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const binary = atob(base64);
|
|
169
|
+
const out = new Uint8Array(binary.length);
|
|
170
|
+
for (let i = 0; i < binary.length; i++) {
|
|
171
|
+
out[i] = binary.charCodeAt(i);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stateValueToBytes(value: unknown): Uint8Array | null {
|
|
177
|
+
if (value instanceof Uint8Array) {
|
|
178
|
+
const encoded = tryReadBase64TextFromBytes(value);
|
|
179
|
+
if (encoded) return base64ToBytes(encoded);
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
const str = readString(value);
|
|
183
|
+
if (!str) return null;
|
|
184
|
+
return base64ToBytes(str);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stateValueToBase64(value: unknown): string | null {
|
|
188
|
+
const str = readString(value);
|
|
189
|
+
if (str) return str;
|
|
190
|
+
if (value instanceof Uint8Array) {
|
|
191
|
+
const encoded = tryReadBase64TextFromBytes(value);
|
|
192
|
+
if (encoded) return encoded;
|
|
193
|
+
return bytesToBase64(value);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createDocFromState(stateValue: unknown): Y.Doc {
|
|
199
|
+
const doc = new Y.Doc();
|
|
200
|
+
const bytes = stateValueToBytes(stateValue);
|
|
201
|
+
if (bytes && bytes.length > 0) {
|
|
202
|
+
Y.applyUpdate(doc, bytes);
|
|
203
|
+
}
|
|
204
|
+
return doc;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function exportSnapshotBase64(doc: Y.Doc): string {
|
|
208
|
+
return bytesToBase64(Y.encodeStateAsUpdate(doc));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function ensureTextContainer(doc: Y.Doc, containerKey: string): string {
|
|
212
|
+
return doc.getText(containerKey).toString();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function ensureXmlFragmentContainer(doc: Y.Doc, containerKey: string): string {
|
|
216
|
+
return doc.getXmlFragment(containerKey).toString();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function replaceText(doc: Y.Doc, containerKey: string, nextText: string): void {
|
|
220
|
+
const text = doc.getText(containerKey);
|
|
221
|
+
const currentLength = text.length;
|
|
222
|
+
doc.transact(() => {
|
|
223
|
+
if (currentLength > 0) {
|
|
224
|
+
text.delete(0, currentLength);
|
|
225
|
+
}
|
|
226
|
+
if (nextText.length > 0) {
|
|
227
|
+
text.insert(0, nextText);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function materializeRuleValue(
|
|
233
|
+
doc: Y.Doc,
|
|
234
|
+
rule: ResolvedYjsServerFieldRule
|
|
235
|
+
): unknown {
|
|
236
|
+
if (rule.kind === 'text') {
|
|
237
|
+
return ensureTextContainer(doc, rule.containerKey);
|
|
238
|
+
}
|
|
239
|
+
return ensureXmlFragmentContainer(doc, rule.containerKey);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function seedRuleValueFromPayload(
|
|
243
|
+
doc: Y.Doc,
|
|
244
|
+
rule: ResolvedYjsServerFieldRule,
|
|
245
|
+
source: Record<string, unknown>
|
|
246
|
+
): void {
|
|
247
|
+
if (rule.kind !== 'text') return;
|
|
248
|
+
const initialText = readString(source[rule.field]);
|
|
249
|
+
if (initialText) {
|
|
250
|
+
replaceText(doc, rule.containerKey, initialText);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createUpdateId(): string {
|
|
255
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
256
|
+
if (typeof randomUUID === 'function') {
|
|
257
|
+
return randomUUID.call(globalThis.crypto);
|
|
258
|
+
}
|
|
259
|
+
const ts = Date.now().toString(36);
|
|
260
|
+
const rnd = Math.random().toString(36).slice(2, 12);
|
|
261
|
+
return `yjs-${ts}-${rnd}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildRuleIndex(rules: readonly YjsServerFieldRule[]): {
|
|
265
|
+
index: RuleIndex;
|
|
266
|
+
normalizedRules: readonly ResolvedYjsServerFieldRule[];
|
|
267
|
+
} {
|
|
268
|
+
const index: RuleIndex = new Map();
|
|
269
|
+
const normalizedRules: ResolvedYjsServerFieldRule[] = [];
|
|
270
|
+
const seen = new Set<string>();
|
|
271
|
+
|
|
272
|
+
for (const rule of rules) {
|
|
273
|
+
if (!rule.table.trim()) {
|
|
274
|
+
throw new Error('YjsServerFieldRule.table cannot be empty');
|
|
275
|
+
}
|
|
276
|
+
if (!rule.field.trim()) {
|
|
277
|
+
throw new Error('YjsServerFieldRule.field cannot be empty');
|
|
278
|
+
}
|
|
279
|
+
if (!rule.stateColumn.trim()) {
|
|
280
|
+
throw new Error('YjsServerFieldRule.stateColumn cannot be empty');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const key = `${rule.table}\u001f${rule.field}`;
|
|
284
|
+
if (seen.has(key)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Duplicate Yjs server rule for table "${rule.table}", field "${rule.field}"`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
seen.add(key);
|
|
290
|
+
|
|
291
|
+
const resolved: ResolvedYjsServerFieldRule = {
|
|
292
|
+
...rule,
|
|
293
|
+
containerKey: rule.containerKey ?? rule.field,
|
|
294
|
+
rowIdField: rule.rowIdField ?? 'id',
|
|
295
|
+
kind: rule.kind ?? 'text',
|
|
296
|
+
};
|
|
297
|
+
normalizedRules.push(resolved);
|
|
298
|
+
|
|
299
|
+
const tableRules = index.get(resolved.table) ?? new Map();
|
|
300
|
+
tableRules.set(resolved.field, resolved);
|
|
301
|
+
index.set(resolved.table, tableRules);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { index, normalizedRules };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function normalizeUpdateEnvelope(
|
|
308
|
+
value: unknown,
|
|
309
|
+
context: string
|
|
310
|
+
): YjsServerUpdateEnvelope {
|
|
311
|
+
if (!isRecord(value)) {
|
|
312
|
+
throw new Error(`${context} must be an object`);
|
|
313
|
+
}
|
|
314
|
+
const updateId = readString(value.updateId);
|
|
315
|
+
const updateBase64 = readString(value.updateBase64);
|
|
316
|
+
if (!updateId) {
|
|
317
|
+
throw new Error(`${context}.updateId must be a non-empty string`);
|
|
318
|
+
}
|
|
319
|
+
if (!updateBase64) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`${context}.updateBase64 must be a non-empty base64 string`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return { updateId, updateBase64 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeUpdateEnvelopes(
|
|
328
|
+
value: unknown,
|
|
329
|
+
context: string
|
|
330
|
+
): YjsServerUpdateEnvelope[] {
|
|
331
|
+
if (Array.isArray(value)) {
|
|
332
|
+
return value.map((entry, i) =>
|
|
333
|
+
normalizeUpdateEnvelope(entry, `${context}[${i}]`)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return [normalizeUpdateEnvelope(value, context)];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/*async function applyYjsTextUpdates(
|
|
340
|
+
args: ApplyYjsTextUpdatesArgs
|
|
341
|
+
): Promise<ApplyYjsTextUpdatesResult> {
|
|
342
|
+
const containerKey = args.containerKey ?? 'text';
|
|
343
|
+
const doc = createDocFromState(args.previousStateBase64);
|
|
344
|
+
try {
|
|
345
|
+
for (const update of args.updates) {
|
|
346
|
+
Y.applyUpdate(doc, base64ToBytes(update.updateBase64));
|
|
347
|
+
}
|
|
348
|
+
const text = ensureTextContainer(doc, containerKey);
|
|
349
|
+
const nextStateBase64 = exportSnapshotBase64(doc);
|
|
350
|
+
return { nextStateBase64, text };
|
|
351
|
+
} finally {
|
|
352
|
+
doc.destroy();
|
|
353
|
+
}
|
|
354
|
+
}*/
|
|
355
|
+
|
|
356
|
+
export async function buildYjsTextUpdate(
|
|
357
|
+
args: BuildYjsTextUpdateArgs
|
|
358
|
+
): Promise<BuildYjsTextUpdateResult> {
|
|
359
|
+
const containerKey = args.containerKey ?? 'text';
|
|
360
|
+
const doc = createDocFromState(args.previousStateBase64);
|
|
361
|
+
try {
|
|
362
|
+
const from = Y.encodeStateVector(doc);
|
|
363
|
+
replaceText(doc, containerKey, args.nextText);
|
|
364
|
+
const update = bytesToBase64(Y.encodeStateAsUpdate(doc, from));
|
|
365
|
+
const nextText = ensureTextContainer(doc, containerKey);
|
|
366
|
+
const nextStateBase64 = exportSnapshotBase64(doc);
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
update: {
|
|
370
|
+
updateId: args.updateId ?? createUpdateId(),
|
|
371
|
+
updateBase64: update,
|
|
372
|
+
},
|
|
373
|
+
nextStateBase64,
|
|
374
|
+
nextText,
|
|
375
|
+
};
|
|
376
|
+
} finally {
|
|
377
|
+
doc.destroy();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function materializeRowFromState(args: {
|
|
382
|
+
table: string;
|
|
383
|
+
row: Record<string, unknown>;
|
|
384
|
+
index: RuleIndex;
|
|
385
|
+
envelopeKey: string;
|
|
386
|
+
stripEnvelope: boolean;
|
|
387
|
+
}): Promise<Record<string, unknown>> {
|
|
388
|
+
const tableRules = args.index.get(args.table);
|
|
389
|
+
if (!tableRules) {
|
|
390
|
+
if (args.stripEnvelope && args.envelopeKey in args.row) {
|
|
391
|
+
const next = { ...args.row };
|
|
392
|
+
delete next[args.envelopeKey];
|
|
393
|
+
return next;
|
|
394
|
+
}
|
|
395
|
+
return args.row;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let nextRow: Record<string, unknown> | null = null;
|
|
399
|
+
const ensureRow = (): Record<string, unknown> => {
|
|
400
|
+
if (nextRow) return nextRow;
|
|
401
|
+
nextRow = { ...args.row };
|
|
402
|
+
return nextRow;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
for (const rule of tableRules.values()) {
|
|
406
|
+
const source = nextRow ?? args.row;
|
|
407
|
+
const stateBase64 = stateValueToBase64(source[rule.stateColumn]);
|
|
408
|
+
if (!stateBase64) continue;
|
|
409
|
+
|
|
410
|
+
const doc = createDocFromState(stateBase64);
|
|
411
|
+
try {
|
|
412
|
+
const nextValue = materializeRuleValue(doc, rule);
|
|
413
|
+
if (source[rule.field] !== nextValue) {
|
|
414
|
+
ensureRow()[rule.field] = nextValue;
|
|
415
|
+
}
|
|
416
|
+
} finally {
|
|
417
|
+
doc.destroy();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (args.stripEnvelope) {
|
|
422
|
+
const source = nextRow ?? args.row;
|
|
423
|
+
if (args.envelopeKey in source) {
|
|
424
|
+
const target = ensureRow();
|
|
425
|
+
delete target[args.envelopeKey];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return nextRow ?? args.row;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function applyYjsEnvelopeToPayload(args: {
|
|
433
|
+
table: string;
|
|
434
|
+
payload: Record<string, unknown>;
|
|
435
|
+
existingRow?: Record<string, unknown> | null;
|
|
436
|
+
index: RuleIndex;
|
|
437
|
+
envelopeKey: string;
|
|
438
|
+
stripEnvelope: boolean;
|
|
439
|
+
strict: boolean;
|
|
440
|
+
}): Promise<Record<string, unknown>> {
|
|
441
|
+
const tableRules = args.index.get(args.table);
|
|
442
|
+
const rawEnvelope = args.payload[args.envelopeKey];
|
|
443
|
+
|
|
444
|
+
if (!tableRules) {
|
|
445
|
+
if (rawEnvelope !== undefined && args.strict) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`Yjs envelope provided for table "${args.table}" without matching rules`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (args.stripEnvelope && rawEnvelope !== undefined) {
|
|
451
|
+
const next = { ...args.payload };
|
|
452
|
+
delete next[args.envelopeKey];
|
|
453
|
+
return next;
|
|
454
|
+
}
|
|
455
|
+
return args.payload;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let nextPayload: Record<string, unknown> | null = null;
|
|
459
|
+
const ensurePayload = (): Record<string, unknown> => {
|
|
460
|
+
if (nextPayload) return nextPayload;
|
|
461
|
+
nextPayload = { ...args.payload };
|
|
462
|
+
return nextPayload;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const sourceEnvelope = rawEnvelope;
|
|
466
|
+
if (sourceEnvelope !== undefined && !isRecord(sourceEnvelope)) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`Yjs payload key "${args.envelopeKey}" must be an object for table "${args.table}"`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (sourceEnvelope) {
|
|
473
|
+
for (const [field, rawUpdateInput] of Object.entries(sourceEnvelope)) {
|
|
474
|
+
const rule = tableRules.get(field);
|
|
475
|
+
if (!rule) {
|
|
476
|
+
if (args.strict) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`No Yjs rule found for envelope field "${field}" on table "${args.table}"`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const updates = normalizeUpdateEnvelopes(
|
|
485
|
+
rawUpdateInput,
|
|
486
|
+
`yjs.${args.table}.${field}`
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const source = nextPayload ?? args.payload;
|
|
490
|
+
const existingSource = args.existingRow ?? null;
|
|
491
|
+
const baseState =
|
|
492
|
+
(existingSource
|
|
493
|
+
? stateValueToBase64(existingSource[rule.stateColumn])
|
|
494
|
+
: null) ??
|
|
495
|
+
stateValueToBase64(source[rule.stateColumn]) ??
|
|
496
|
+
null;
|
|
497
|
+
|
|
498
|
+
const doc = createDocFromState(baseState);
|
|
499
|
+
try {
|
|
500
|
+
if (!baseState) {
|
|
501
|
+
seedRuleValueFromPayload(doc, rule, source);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
for (const update of updates) {
|
|
505
|
+
Y.applyUpdate(doc, base64ToBytes(update.updateBase64));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const nextValue = materializeRuleValue(doc, rule);
|
|
509
|
+
const nextStateBase64 = exportSnapshotBase64(doc);
|
|
510
|
+
const target = ensurePayload();
|
|
511
|
+
target[rule.field] = nextValue;
|
|
512
|
+
target[rule.stateColumn] = nextStateBase64;
|
|
513
|
+
} finally {
|
|
514
|
+
doc.destroy();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (args.stripEnvelope) {
|
|
520
|
+
const source = nextPayload ?? args.payload;
|
|
521
|
+
if (args.envelopeKey in source) {
|
|
522
|
+
const target = ensurePayload();
|
|
523
|
+
delete target[args.envelopeKey];
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return nextPayload ?? args.payload;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function createYjsServerModule(
|
|
531
|
+
options: CreateYjsServerModuleOptions
|
|
532
|
+
): YjsServerModule {
|
|
533
|
+
if (options.rules.length === 0) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
'createYjsServerModule requires at least one table/field rule'
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const envelopeKey = options.envelopeKey ?? YJS_PAYLOAD_KEY;
|
|
540
|
+
const strict = options.strict ?? true;
|
|
541
|
+
const stripEnvelope = options.stripEnvelope ?? true;
|
|
542
|
+
const { index } = buildRuleIndex(options.rules);
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
name: options.name ?? 'crdt-yjs-server',
|
|
546
|
+
rules: options.rules,
|
|
547
|
+
envelopeKey,
|
|
548
|
+
|
|
549
|
+
async applyPayload(args): Promise<Record<string, unknown>> {
|
|
550
|
+
return await applyYjsEnvelopeToPayload({
|
|
551
|
+
table: args.table,
|
|
552
|
+
payload: args.payload,
|
|
553
|
+
existingRow: args.existingRow,
|
|
554
|
+
index,
|
|
555
|
+
envelopeKey,
|
|
556
|
+
stripEnvelope,
|
|
557
|
+
strict,
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
async materializeRow(args): Promise<Record<string, unknown>> {
|
|
562
|
+
return await materializeRowFromState({
|
|
563
|
+
table: args.table,
|
|
564
|
+
row: args.row,
|
|
565
|
+
index,
|
|
566
|
+
envelopeKey,
|
|
567
|
+
stripEnvelope,
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function buildTableRowIdFieldIndex(
|
|
574
|
+
rules: readonly YjsServerFieldRule[]
|
|
575
|
+
): Map<string, string> {
|
|
576
|
+
const tableRowIdFields = new Map<string, string>();
|
|
577
|
+
|
|
578
|
+
for (const rule of rules) {
|
|
579
|
+
const rowIdField = rule.rowIdField ?? 'id';
|
|
580
|
+
const existing = tableRowIdFields.get(rule.table);
|
|
581
|
+
if (existing && existing !== rowIdField) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`Yjs rules for table "${rule.table}" must use a single rowIdField`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
tableRowIdFields.set(rule.table, rowIdField);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return tableRowIdFields;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function materializeAppliedResult(
|
|
593
|
+
yjsModule: YjsServerModule,
|
|
594
|
+
opTable: string,
|
|
595
|
+
applied: ApplyOperationResult
|
|
596
|
+
): Promise<ApplyOperationResult> {
|
|
597
|
+
let nextResult: ApplyOperationResult['result'] = applied.result;
|
|
598
|
+
let resultChanged = false;
|
|
599
|
+
|
|
600
|
+
if (nextResult.status === 'conflict' && isRecord(nextResult.server_row)) {
|
|
601
|
+
const materializedServerRow = await yjsModule.materializeRow({
|
|
602
|
+
table: opTable,
|
|
603
|
+
row: nextResult.server_row,
|
|
604
|
+
});
|
|
605
|
+
if (materializedServerRow !== nextResult.server_row) {
|
|
606
|
+
nextResult = {
|
|
607
|
+
...nextResult,
|
|
608
|
+
server_row: materializedServerRow,
|
|
609
|
+
};
|
|
610
|
+
resultChanged = true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let emittedChanged = false;
|
|
615
|
+
const nextEmitted: ApplyOperationResult['emittedChanges'] = [];
|
|
616
|
+
for (const emitted of applied.emittedChanges) {
|
|
617
|
+
if (emitted.op !== 'upsert' || !isRecord(emitted.row_json)) {
|
|
618
|
+
nextEmitted.push(emitted);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const materializedRow = await yjsModule.materializeRow({
|
|
623
|
+
table: emitted.table,
|
|
624
|
+
row: emitted.row_json,
|
|
625
|
+
});
|
|
626
|
+
if (materializedRow !== emitted.row_json) {
|
|
627
|
+
emittedChanged = true;
|
|
628
|
+
nextEmitted.push({
|
|
629
|
+
...emitted,
|
|
630
|
+
row_json: materializedRow,
|
|
631
|
+
});
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
nextEmitted.push(emitted);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!resultChanged && !emittedChanged) {
|
|
639
|
+
return applied;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
result: nextResult,
|
|
644
|
+
emittedChanges: emittedChanged ? nextEmitted : applied.emittedChanges,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function createYjsServerPushPlugin(
|
|
649
|
+
options: CreateYjsServerPushPluginOptions
|
|
650
|
+
): SyncServerPushPlugin {
|
|
651
|
+
const yjsModule = createYjsServerModule(options);
|
|
652
|
+
const tableRowIdFields = buildTableRowIdFieldIndex(options.rules);
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
name: options.name ?? yjsModule.name,
|
|
656
|
+
priority: options.priority ?? ServerPushPluginPriority.CRDT,
|
|
657
|
+
|
|
658
|
+
async beforeApplyOperation(args) {
|
|
659
|
+
const op = args.op;
|
|
660
|
+
if (op.op !== 'upsert' || !isRecord(op.payload)) {
|
|
661
|
+
return op;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const rowIdField = tableRowIdFields.get(op.table);
|
|
665
|
+
let existingRow: Record<string, unknown> | null = null;
|
|
666
|
+
|
|
667
|
+
if (rowIdField) {
|
|
668
|
+
const loadedRows = await sql<Record<string, unknown>>`
|
|
669
|
+
select *
|
|
670
|
+
from ${sql.table(op.table)}
|
|
671
|
+
where ${sql.ref(rowIdField)} = ${sql.val(op.row_id)}
|
|
672
|
+
limit ${sql.val(1)}
|
|
673
|
+
`.execute(args.ctx.trx);
|
|
674
|
+
const loadedRow = loadedRows.rows[0];
|
|
675
|
+
if (loadedRow && isRecord(loadedRow)) {
|
|
676
|
+
existingRow = loadedRow;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const nextPayload = await yjsModule.applyPayload({
|
|
681
|
+
table: op.table,
|
|
682
|
+
rowId: op.row_id,
|
|
683
|
+
payload: op.payload,
|
|
684
|
+
existingRow,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
if (nextPayload === op.payload) return op;
|
|
688
|
+
return {
|
|
689
|
+
...op,
|
|
690
|
+
payload: nextPayload,
|
|
691
|
+
};
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
async afterApplyOperation(args) {
|
|
695
|
+
return await materializeAppliedResult(
|
|
696
|
+
yjsModule,
|
|
697
|
+
args.op.table,
|
|
698
|
+
args.applied
|
|
699
|
+
);
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|