amalgm 0.1.155 → 0.1.156
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/lib/shared-realtime-tunnel.js +518 -0
- package/lib/tunnel-events.js +27 -0
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +75 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +187 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +144 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +230 -24
- package/runtime/scripts/amalgm-mcp/state/events.js +20 -2
- package/runtime/scripts/amalgm-mcp/state/mutations.js +800 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +271 -0
- package/runtime/scripts/amalgm-mcp/state/replicas.js +200 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +267 -0
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +337 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +283 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { cachedStatement, openLocalDb } = require('./db');
|
|
6
|
+
const { appendStateEvent } = require('./events');
|
|
7
|
+
const { registerReplica } = require('./replicas');
|
|
8
|
+
|
|
9
|
+
const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
10
|
+
const PROMOTION_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
11
|
+
|
|
12
|
+
function invalid(message, statusCode = 400) {
|
|
13
|
+
return Object.assign(new Error(message), { statusCode });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requiredText(value, name, max = 1024) {
|
|
17
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
18
|
+
if (!text || text.length > max || /[\u0000-\u001f\u007f]/.test(text)) {
|
|
19
|
+
throw invalid(`${name} must be a non-empty string of at most ${max} characters`);
|
|
20
|
+
}
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeRow(row) {
|
|
25
|
+
if (!row) return null;
|
|
26
|
+
return {
|
|
27
|
+
promotionId: row.promotion_id,
|
|
28
|
+
resourceId: row.resource_id,
|
|
29
|
+
localResource: row.local_resource,
|
|
30
|
+
localPath: row.local_path,
|
|
31
|
+
contract: row.contract,
|
|
32
|
+
schemaVersion: Number(row.schema_version),
|
|
33
|
+
snapshotBase64: row.snapshot_b64,
|
|
34
|
+
snapshotChecksum: row.snapshot_checksum,
|
|
35
|
+
snapshotBytes: Number(row.snapshot_bytes),
|
|
36
|
+
contentBytes: row.content_bytes == null ? null : Number(row.content_bytes),
|
|
37
|
+
name: row.name || null,
|
|
38
|
+
mediaType: row.media_type || null,
|
|
39
|
+
state: row.promotion_state,
|
|
40
|
+
attemptCount: Number(row.attempt_count),
|
|
41
|
+
nextAttemptAt: row.next_attempt_at || null,
|
|
42
|
+
lastError: row.last_error || null,
|
|
43
|
+
snapshotKey: row.snapshot_key || null,
|
|
44
|
+
createdAt: row.created_at,
|
|
45
|
+
updatedAt: row.updated_at,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function enqueuePromotion(input, options = {}) {
|
|
50
|
+
const promotionId = requiredText(input?.promotionId, 'promotionId', 160);
|
|
51
|
+
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
52
|
+
if (!PROMOTION_ID.test(promotionId)) throw invalid('promotionId has an invalid format');
|
|
53
|
+
if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
|
|
54
|
+
const localResource = requiredText(input?.localResource, 'localResource');
|
|
55
|
+
const localPath = requiredText(input?.localPath, 'localPath', 4096);
|
|
56
|
+
const contract = requiredText(input?.contract || 'text-yjs@1', 'contract', 128);
|
|
57
|
+
const schemaVersion = Number(input?.schemaVersion || 1);
|
|
58
|
+
if (!Number.isSafeInteger(schemaVersion) || schemaVersion < 1) {
|
|
59
|
+
throw invalid('schemaVersion must be a positive integer');
|
|
60
|
+
}
|
|
61
|
+
const snapshotBase64 = requiredText(input?.snapshotBase64, 'snapshotBase64', 8 * 1024 * 1024);
|
|
62
|
+
const snapshot = Buffer.from(snapshotBase64, 'base64');
|
|
63
|
+
const snapshotChecksum = crypto.createHash('sha256').update(snapshot).digest('hex');
|
|
64
|
+
const contentBytes = input?.contentBytes == null ? null : Number(input.contentBytes);
|
|
65
|
+
if (contentBytes != null && (!Number.isSafeInteger(contentBytes) || contentBytes < 0)) {
|
|
66
|
+
throw invalid('contentBytes must be a non-negative integer');
|
|
67
|
+
}
|
|
68
|
+
const database = options.database || openLocalDb();
|
|
69
|
+
const existing = cachedStatement(
|
|
70
|
+
database,
|
|
71
|
+
'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?',
|
|
72
|
+
).get(promotionId);
|
|
73
|
+
if (existing) {
|
|
74
|
+
const row = normalizeRow(existing);
|
|
75
|
+
if (
|
|
76
|
+
row.resourceId !== resourceId
|
|
77
|
+
|| row.localResource !== localResource
|
|
78
|
+
|| row.snapshotChecksum !== snapshotChecksum
|
|
79
|
+
) {
|
|
80
|
+
throw invalid(`promotionId ${promotionId} was reused with different content`, 409);
|
|
81
|
+
}
|
|
82
|
+
return { promotion: row, duplicate: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
cachedStatement(database, `
|
|
87
|
+
INSERT INTO shared_resource_promotions (
|
|
88
|
+
promotion_id,
|
|
89
|
+
resource_id,
|
|
90
|
+
local_resource,
|
|
91
|
+
local_path,
|
|
92
|
+
contract,
|
|
93
|
+
schema_version,
|
|
94
|
+
snapshot_b64,
|
|
95
|
+
snapshot_checksum,
|
|
96
|
+
snapshot_bytes,
|
|
97
|
+
content_bytes,
|
|
98
|
+
name,
|
|
99
|
+
media_type,
|
|
100
|
+
created_at,
|
|
101
|
+
updated_at
|
|
102
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
|
+
`).run(
|
|
104
|
+
promotionId,
|
|
105
|
+
resourceId,
|
|
106
|
+
localResource,
|
|
107
|
+
localPath,
|
|
108
|
+
contract,
|
|
109
|
+
schemaVersion,
|
|
110
|
+
snapshotBase64,
|
|
111
|
+
snapshotChecksum,
|
|
112
|
+
snapshot.length,
|
|
113
|
+
contentBytes,
|
|
114
|
+
input?.name || path.basename(localPath),
|
|
115
|
+
input?.mediaType || 'application/vnd.amalgm.text-yjs-snapshot',
|
|
116
|
+
now,
|
|
117
|
+
now,
|
|
118
|
+
);
|
|
119
|
+
const promotion = normalizeRow(
|
|
120
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?')
|
|
121
|
+
.get(promotionId),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Wake the already-open internal Local Live stream used by the tunnel
|
|
125
|
+
// process. This is a control event, not a cloud mutation/version.
|
|
126
|
+
appendStateEvent({
|
|
127
|
+
resource: 'shared:promotions',
|
|
128
|
+
op: 'update',
|
|
129
|
+
id: promotionId,
|
|
130
|
+
sharedResourceId: resourceId,
|
|
131
|
+
value: { state: 'pending' },
|
|
132
|
+
source: 'shared:promotion',
|
|
133
|
+
});
|
|
134
|
+
return { promotion, duplicate: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function claimPromotions(options = {}) {
|
|
138
|
+
const database = options.database || openLocalDb();
|
|
139
|
+
const limit = Math.max(1, Math.min(Number(options.limit) || 4, 20));
|
|
140
|
+
const leaseMs = Math.max(1000, Number(options.leaseMs) || 30_000);
|
|
141
|
+
const now = new Date().toISOString();
|
|
142
|
+
const leaseUntil = new Date(Date.now() + leaseMs).toISOString();
|
|
143
|
+
return database.transaction(() => {
|
|
144
|
+
const rows = cachedStatement(database, `
|
|
145
|
+
SELECT * FROM shared_resource_promotions
|
|
146
|
+
WHERE promotion_state = 'pending'
|
|
147
|
+
OR (promotion_state = 'syncing' AND (next_attempt_at IS NULL OR next_attempt_at <= ?))
|
|
148
|
+
ORDER BY created_at ASC
|
|
149
|
+
LIMIT ?
|
|
150
|
+
`).all(now, limit);
|
|
151
|
+
const update = cachedStatement(database, `
|
|
152
|
+
UPDATE shared_resource_promotions
|
|
153
|
+
SET promotion_state = 'syncing',
|
|
154
|
+
attempt_count = attempt_count + 1,
|
|
155
|
+
next_attempt_at = ?,
|
|
156
|
+
updated_at = ?
|
|
157
|
+
WHERE promotion_id = ?
|
|
158
|
+
`);
|
|
159
|
+
return rows.map((row) => {
|
|
160
|
+
update.run(leaseUntil, now, row.promotion_id);
|
|
161
|
+
const promotion = normalizeRow(
|
|
162
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?')
|
|
163
|
+
.get(row.promotion_id),
|
|
164
|
+
);
|
|
165
|
+
return {
|
|
166
|
+
promotion,
|
|
167
|
+
frame: {
|
|
168
|
+
promotionId: promotion.promotionId,
|
|
169
|
+
resourceId: promotion.resourceId,
|
|
170
|
+
contract: promotion.contract,
|
|
171
|
+
schemaVersion: promotion.schemaVersion,
|
|
172
|
+
snapshotBase64: promotion.snapshotBase64,
|
|
173
|
+
snapshotChecksum: promotion.snapshotChecksum,
|
|
174
|
+
snapshotBytes: promotion.snapshotBytes,
|
|
175
|
+
contentBytes: promotion.contentBytes,
|
|
176
|
+
name: promotion.name,
|
|
177
|
+
mediaType: promotion.mediaType,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
|
|
185
|
+
const promotionId = requiredText(promotionIdInput, 'promotionId', 160);
|
|
186
|
+
const database = options.database || openLocalDb();
|
|
187
|
+
return database.transaction(() => {
|
|
188
|
+
const row = cachedStatement(
|
|
189
|
+
database,
|
|
190
|
+
'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?',
|
|
191
|
+
).get(promotionId);
|
|
192
|
+
if (!row) throw invalid(`unknown promotion ${promotionId}`, 404);
|
|
193
|
+
const promotion = normalizeRow(row);
|
|
194
|
+
if (acknowledgement?.resourceId !== promotion.resourceId) {
|
|
195
|
+
throw invalid('promotion acknowledgement resource mismatch', 409);
|
|
196
|
+
}
|
|
197
|
+
if (promotion.state === 'promoted') return { promotion, duplicate: true };
|
|
198
|
+
|
|
199
|
+
registerReplica({
|
|
200
|
+
resourceId: promotion.resourceId,
|
|
201
|
+
localResource: promotion.localResource,
|
|
202
|
+
localPath: promotion.localPath,
|
|
203
|
+
contract: promotion.contract,
|
|
204
|
+
schemaVersion: promotion.schemaVersion,
|
|
205
|
+
authorityEpoch: acknowledgement?.authorityEpoch,
|
|
206
|
+
appliedVersion: acknowledgement?.headVersion ?? 0,
|
|
207
|
+
snapshotVersion: acknowledgement?.snapshotVersion ?? 0,
|
|
208
|
+
snapshotChecksum: acknowledgement?.snapshotChecksum,
|
|
209
|
+
}, { database });
|
|
210
|
+
|
|
211
|
+
const now = new Date().toISOString();
|
|
212
|
+
cachedStatement(database, `
|
|
213
|
+
UPDATE shared_resource_promotions
|
|
214
|
+
SET promotion_state = 'promoted',
|
|
215
|
+
next_attempt_at = NULL,
|
|
216
|
+
last_error = NULL,
|
|
217
|
+
snapshot_key = ?,
|
|
218
|
+
updated_at = ?
|
|
219
|
+
WHERE promotion_id = ?
|
|
220
|
+
`).run(acknowledgement?.snapshotKey || null, now, promotionId);
|
|
221
|
+
return {
|
|
222
|
+
promotion: normalizeRow(
|
|
223
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?')
|
|
224
|
+
.get(promotionId),
|
|
225
|
+
),
|
|
226
|
+
duplicate: false,
|
|
227
|
+
};
|
|
228
|
+
})();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function failPromotion(promotionIdInput, failure, options = {}) {
|
|
232
|
+
const promotionId = requiredText(promotionIdInput, 'promotionId', 160);
|
|
233
|
+
const database = options.database || openLocalDb();
|
|
234
|
+
const retryable = failure?.retryable !== false;
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
const result = cachedStatement(database, `
|
|
237
|
+
UPDATE shared_resource_promotions
|
|
238
|
+
SET promotion_state = ?,
|
|
239
|
+
next_attempt_at = ?,
|
|
240
|
+
last_error = ?,
|
|
241
|
+
updated_at = ?
|
|
242
|
+
WHERE promotion_id = ? AND promotion_state != 'promoted'
|
|
243
|
+
`).run(
|
|
244
|
+
retryable ? 'syncing' : 'rejected',
|
|
245
|
+
retryable ? new Date(Date.now() + 1000).toISOString() : null,
|
|
246
|
+
String(failure?.message || 'promotion failed').slice(0, 4000),
|
|
247
|
+
now,
|
|
248
|
+
promotionId,
|
|
249
|
+
);
|
|
250
|
+
if (!result.changes) throw invalid(`unknown or completed promotion ${promotionId}`, 404);
|
|
251
|
+
return normalizeRow(
|
|
252
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?')
|
|
253
|
+
.get(promotionId),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getPromotion(promotionId, options = {}) {
|
|
258
|
+
const database = options.database || openLocalDb();
|
|
259
|
+
return normalizeRow(
|
|
260
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_promotions WHERE promotion_id = ?')
|
|
261
|
+
.get(promotionId),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
acknowledgePromotion,
|
|
267
|
+
claimPromotions,
|
|
268
|
+
enqueuePromotion,
|
|
269
|
+
failPromotion,
|
|
270
|
+
getPromotion,
|
|
271
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local bindings for cloud-authoritative resources.
|
|
5
|
+
*
|
|
6
|
+
* The binding is the answer to "is this edit cloud-backed?". That decision
|
|
7
|
+
* is owned by the runtime and persisted in SQLite; it is never trusted from
|
|
8
|
+
* a browser mutation payload.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { cachedStatement, openLocalDb } = require('./db');
|
|
12
|
+
|
|
13
|
+
const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
14
|
+
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
15
|
+
|
|
16
|
+
function invalid(message, statusCode = 400) {
|
|
17
|
+
return Object.assign(new Error(message), { statusCode });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function requiredText(value, name, max = 1024) {
|
|
21
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
22
|
+
if (!text || text.length > max || /[\u0000-\u001f\u007f]/.test(text)) {
|
|
23
|
+
throw invalid(`${name} must be a non-empty string of at most ${max} characters`);
|
|
24
|
+
}
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function integer(value, name, minimum) {
|
|
29
|
+
const number = Number(value);
|
|
30
|
+
if (!Number.isSafeInteger(number) || number < minimum) {
|
|
31
|
+
throw invalid(`${name} must be an integer >= ${minimum}`);
|
|
32
|
+
}
|
|
33
|
+
return number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeReplica(row) {
|
|
37
|
+
if (!row) return null;
|
|
38
|
+
return {
|
|
39
|
+
resourceId: row.resource_id,
|
|
40
|
+
localResource: row.local_resource,
|
|
41
|
+
localPath: row.local_path,
|
|
42
|
+
contract: row.contract,
|
|
43
|
+
schemaVersion: Number(row.schema_version),
|
|
44
|
+
authorityEpoch: Number(row.authority_epoch),
|
|
45
|
+
appliedVersion: Number(row.applied_version),
|
|
46
|
+
snapshotVersion: Number(row.snapshot_version),
|
|
47
|
+
snapshotChecksum: row.snapshot_checksum || null,
|
|
48
|
+
syncStatus: row.sync_status,
|
|
49
|
+
lastError: row.last_error || null,
|
|
50
|
+
createdAt: row.created_at,
|
|
51
|
+
updatedAt: row.updated_at,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function registerReplica(input, options = {}) {
|
|
56
|
+
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
57
|
+
if (!SHARED_RESOURCE_ID.test(resourceId)) {
|
|
58
|
+
throw invalid('resourceId must be an opaque id beginning with shr_');
|
|
59
|
+
}
|
|
60
|
+
const localResource = requiredText(input?.localResource, 'localResource');
|
|
61
|
+
const localPath = requiredText(input?.localPath, 'localPath', 4096);
|
|
62
|
+
const contract = requiredText(input?.contract, 'contract', 128);
|
|
63
|
+
if (!CONTRACT.test(contract)) throw invalid('contract has an invalid format');
|
|
64
|
+
const schemaVersion = integer(input?.schemaVersion, 'schemaVersion', 1);
|
|
65
|
+
const authorityEpoch = integer(input?.authorityEpoch, 'authorityEpoch', 1);
|
|
66
|
+
const appliedVersion = integer(input?.appliedVersion ?? 0, 'appliedVersion', 0);
|
|
67
|
+
const snapshotVersion = integer(input?.snapshotVersion ?? appliedVersion, 'snapshotVersion', 0);
|
|
68
|
+
if (snapshotVersion > appliedVersion) throw invalid('snapshotVersion cannot exceed appliedVersion');
|
|
69
|
+
const snapshotChecksum = input?.snapshotChecksum == null
|
|
70
|
+
? null
|
|
71
|
+
: requiredText(input.snapshotChecksum, 'snapshotChecksum', 64);
|
|
72
|
+
if (snapshotChecksum && !/^[0-9a-f]{64}$/.test(snapshotChecksum)) {
|
|
73
|
+
throw invalid('snapshotChecksum must be lowercase sha256 hex');
|
|
74
|
+
}
|
|
75
|
+
const database = options.database || openLocalDb();
|
|
76
|
+
const existing = cachedStatement(
|
|
77
|
+
database,
|
|
78
|
+
'SELECT * FROM shared_resource_replicas WHERE resource_id = ?',
|
|
79
|
+
).get(resourceId);
|
|
80
|
+
if (existing) {
|
|
81
|
+
const normalized = normalizeReplica(existing);
|
|
82
|
+
if (
|
|
83
|
+
normalized.localResource !== localResource
|
|
84
|
+
|| normalized.localPath !== localPath
|
|
85
|
+
|| normalized.contract !== contract
|
|
86
|
+
|| normalized.schemaVersion !== schemaVersion
|
|
87
|
+
) {
|
|
88
|
+
throw invalid(`shared resource ${resourceId} is already bound differently`, 409);
|
|
89
|
+
}
|
|
90
|
+
return { replica: normalized, duplicate: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
try {
|
|
95
|
+
cachedStatement(database, `
|
|
96
|
+
INSERT INTO shared_resource_replicas (
|
|
97
|
+
resource_id,
|
|
98
|
+
local_resource,
|
|
99
|
+
local_path,
|
|
100
|
+
contract,
|
|
101
|
+
schema_version,
|
|
102
|
+
authority_epoch,
|
|
103
|
+
applied_version,
|
|
104
|
+
snapshot_version,
|
|
105
|
+
snapshot_checksum,
|
|
106
|
+
sync_status,
|
|
107
|
+
created_at,
|
|
108
|
+
updated_at
|
|
109
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'synced', ?, ?)
|
|
110
|
+
`).run(
|
|
111
|
+
resourceId,
|
|
112
|
+
localResource,
|
|
113
|
+
localPath,
|
|
114
|
+
contract,
|
|
115
|
+
schemaVersion,
|
|
116
|
+
authorityEpoch,
|
|
117
|
+
appliedVersion,
|
|
118
|
+
snapshotVersion,
|
|
119
|
+
snapshotChecksum,
|
|
120
|
+
now,
|
|
121
|
+
now,
|
|
122
|
+
);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (String(error?.code || '').includes('CONSTRAINT')) {
|
|
125
|
+
throw invalid('that local file is already bound to another shared resource', 409);
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
replica: normalizeReplica(
|
|
131
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?')
|
|
132
|
+
.get(resourceId),
|
|
133
|
+
),
|
|
134
|
+
duplicate: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getReplicaByResourceId(resourceId, options = {}) {
|
|
139
|
+
const database = options.database || openLocalDb();
|
|
140
|
+
return normalizeReplica(
|
|
141
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?')
|
|
142
|
+
.get(resourceId),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getReplicaByLocalResource(localResource, options = {}) {
|
|
147
|
+
const database = options.database || openLocalDb();
|
|
148
|
+
return normalizeReplica(
|
|
149
|
+
cachedStatement(database, 'SELECT * FROM shared_resource_replicas WHERE local_resource = ?')
|
|
150
|
+
.get(localResource),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function listReplicas(options = {}) {
|
|
155
|
+
const database = options.database || openLocalDb();
|
|
156
|
+
return cachedStatement(database, 'SELECT * FROM shared_resource_replicas ORDER BY created_at ASC')
|
|
157
|
+
.all()
|
|
158
|
+
.map(normalizeReplica);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function refreshReplicaAuthority(input, options = {}) {
|
|
162
|
+
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
163
|
+
const authorityEpoch = integer(input?.authorityEpoch, 'authorityEpoch', 1);
|
|
164
|
+
const headVersion = integer(input?.headVersion, 'headVersion', 0);
|
|
165
|
+
const database = options.database || openLocalDb();
|
|
166
|
+
const row = cachedStatement(
|
|
167
|
+
database,
|
|
168
|
+
'SELECT * FROM shared_resource_replicas WHERE resource_id = ?',
|
|
169
|
+
).get(resourceId);
|
|
170
|
+
if (!row) throw invalid(`unknown shared replica ${resourceId}`, 404);
|
|
171
|
+
if (
|
|
172
|
+
(input?.contract && input.contract !== row.contract)
|
|
173
|
+
|| (input?.schemaVersion && Number(input.schemaVersion) !== Number(row.schema_version))
|
|
174
|
+
) {
|
|
175
|
+
throw invalid(`shared resource ${resourceId} contract changed`, 409);
|
|
176
|
+
}
|
|
177
|
+
if (authorityEpoch < Number(row.authority_epoch)) {
|
|
178
|
+
return normalizeReplica(row);
|
|
179
|
+
}
|
|
180
|
+
cachedStatement(database, `
|
|
181
|
+
UPDATE shared_resource_replicas
|
|
182
|
+
SET authority_epoch = ?,
|
|
183
|
+
sync_status = CASE WHEN applied_version < ? THEN 'syncing' ELSE sync_status END,
|
|
184
|
+
last_error = CASE
|
|
185
|
+
WHEN applied_version < ? THEN 'official tail recovery required'
|
|
186
|
+
ELSE last_error
|
|
187
|
+
END,
|
|
188
|
+
updated_at = ?
|
|
189
|
+
WHERE resource_id = ?
|
|
190
|
+
`).run(authorityEpoch, headVersion, headVersion, new Date().toISOString(), resourceId);
|
|
191
|
+
return getReplicaByResourceId(resourceId, { database });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
getReplicaByLocalResource,
|
|
196
|
+
getReplicaByResourceId,
|
|
197
|
+
listReplicas,
|
|
198
|
+
refreshReplicaAuthority,
|
|
199
|
+
registerReplica,
|
|
200
|
+
};
|
|
@@ -98,6 +98,10 @@ async function handleDocOpen(body, sendJson) {
|
|
|
98
98
|
|
|
99
99
|
async function handleDocUpdate(body, sendJson) {
|
|
100
100
|
try {
|
|
101
|
+
if (Array.isArray(body?.mutations)) {
|
|
102
|
+
sendJson(200, require('./docs').applyDocMutations(body?.path, body.mutations));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
101
105
|
const updates = body?.updates ?? body?.update;
|
|
102
106
|
if (updates == null) {
|
|
103
107
|
sendJson(400, { error: 'body requires "updates" (array of base64 Yjs updates) or "update"' });
|