@syncular/server 0.15.45 → 0.15.46
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 +134 -4
- package/dist/admin.d.ts +10 -4
- package/dist/admin.js +10 -0
- package/dist/authoritative-query.d.ts +20 -0
- package/dist/authoritative-query.js +184 -0
- package/dist/context.d.ts +9 -0
- package/dist/context.js +2 -0
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +216 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +43 -1
- package/dist/events.d.ts +52 -3
- package/dist/handler.js +4 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/operations-realtime.d.ts +16 -0
- package/dist/operations-realtime.js +196 -0
- package/dist/operations.d.ts +97 -0
- package/dist/operations.js +392 -0
- package/dist/postgres-storage.d.ts +11 -2
- package/dist/postgres-storage.js +220 -0
- package/dist/push.d.ts +8 -2
- package/dist/push.js +75 -21
- package/dist/reactions.d.ts +167 -0
- package/dist/reactions.js +442 -0
- package/dist/realtime.js +4 -1
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +20 -0
- package/dist/sqlite-storage.d.ts +10 -1
- package/dist/sqlite-storage.js +215 -0
- package/dist/storage.d.ts +109 -0
- package/dist/validate.js +1 -0
- package/package.json +2 -2
- package/src/admin.ts +27 -3
- package/src/authoritative-query.ts +218 -0
- package/src/context.ts +10 -0
- package/src/d1-storage.ts +352 -0
- package/src/errors.ts +43 -1
- package/src/events.ts +64 -2
- package/src/handler.ts +13 -1
- package/src/index.ts +32 -0
- package/src/operations-realtime.ts +272 -0
- package/src/operations.ts +720 -0
- package/src/postgres-storage.ts +351 -0
- package/src/push.ts +97 -29
- package/src/reactions.ts +741 -0
- package/src/realtime.ts +7 -1
- package/src/sqlite-dialect.ts +20 -0
- package/src/sqlite-storage.ts +365 -0
- package/src/storage.ts +165 -0
- package/src/validate.ts +1 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeRow,
|
|
3
|
+
decodeRemoteOperationRequest,
|
|
4
|
+
encodeRow,
|
|
5
|
+
encodeRemoteOperationResponse,
|
|
6
|
+
type PushOperation,
|
|
7
|
+
type RemoteOperationResponse,
|
|
8
|
+
type RowValue,
|
|
9
|
+
type ScopeMap,
|
|
10
|
+
} from '@syncular/core';
|
|
11
|
+
import type { SyncRequestContext } from './context';
|
|
12
|
+
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context';
|
|
13
|
+
import { SyncError, syncError } from './errors';
|
|
14
|
+
import { processPushOperationsWithTrace } from './push';
|
|
15
|
+
import { compileSchema } from './schema';
|
|
16
|
+
import { authorizeWrite, type ResolvedScopes } from './scopes';
|
|
17
|
+
import type { AuthoritativeQueryValue } from './storage';
|
|
18
|
+
import type { StorageTransaction } from './storage';
|
|
19
|
+
import { toValidateRow, type ValidateRow } from './validate';
|
|
20
|
+
|
|
21
|
+
export interface RemoteQueryDependency {
|
|
22
|
+
readonly table: string;
|
|
23
|
+
readonly scopeKeys?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RemoteQueryCoverage {
|
|
27
|
+
readonly base: {
|
|
28
|
+
readonly table: string;
|
|
29
|
+
readonly variable: string;
|
|
30
|
+
readonly fixedScopes?: Readonly<Record<string, readonly string[]>>;
|
|
31
|
+
};
|
|
32
|
+
readonly units: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Structural subset implemented by generated NamedQuery descriptors. */
|
|
36
|
+
export interface AuthoritativeQueryDescriptor<Params = undefined> {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly hasParams: boolean;
|
|
39
|
+
readonly sql: string;
|
|
40
|
+
readonly tables: readonly string[];
|
|
41
|
+
readonly resultColumns: readonly {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly type:
|
|
44
|
+
| 'string'
|
|
45
|
+
| 'integer'
|
|
46
|
+
| 'float'
|
|
47
|
+
| 'boolean'
|
|
48
|
+
| 'json'
|
|
49
|
+
| 'bytes'
|
|
50
|
+
| 'blob_ref'
|
|
51
|
+
| 'crdt';
|
|
52
|
+
readonly nullable: boolean;
|
|
53
|
+
}[];
|
|
54
|
+
readonly bind: (params: Params) => readonly AuthoritativeQueryValue[];
|
|
55
|
+
readonly sqlFor?: (params: Params) => string;
|
|
56
|
+
readonly dependencies: (params: Params) => readonly RemoteQueryDependency[];
|
|
57
|
+
readonly coverage: (params: Params) => readonly RemoteQueryCoverage[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RemoteOperationAuthContext {
|
|
61
|
+
readonly actorId: string;
|
|
62
|
+
readonly partition: string;
|
|
63
|
+
readonly clientId: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type RegisteredRemoteQuery = {
|
|
67
|
+
readonly kind: 'query';
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly tables: readonly string[];
|
|
70
|
+
readonly run: (
|
|
71
|
+
ctx: SyncRequestContext,
|
|
72
|
+
clientId: string,
|
|
73
|
+
params: unknown,
|
|
74
|
+
) => Promise<RemoteOperationResponse>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface RemoteCommandDescriptor<Input = undefined> {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly __input?: Input;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type CommandMutation =
|
|
83
|
+
| {
|
|
84
|
+
readonly table: string;
|
|
85
|
+
readonly op: 'upsert';
|
|
86
|
+
readonly values: Readonly<Record<string, RowValue>>;
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
readonly table: string;
|
|
90
|
+
readonly op: 'delete';
|
|
91
|
+
readonly rowId: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export interface RemoteCommandContext {
|
|
95
|
+
readonly actorId: string;
|
|
96
|
+
readonly partition: string;
|
|
97
|
+
readonly clientId: string;
|
|
98
|
+
readonly operationId: string;
|
|
99
|
+
readonly requestId: string;
|
|
100
|
+
getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface RemoteCommandOptions<Input> {
|
|
104
|
+
readonly authorize: (
|
|
105
|
+
context: RemoteOperationAuthContext,
|
|
106
|
+
input: Input,
|
|
107
|
+
) => boolean | Promise<boolean>;
|
|
108
|
+
readonly run: (
|
|
109
|
+
context: RemoteCommandContext,
|
|
110
|
+
input: Input,
|
|
111
|
+
) => readonly CommandMutation[] | Promise<readonly CommandMutation[]>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type RegisteredRemoteCommand = {
|
|
115
|
+
readonly kind: 'command';
|
|
116
|
+
readonly id: string;
|
|
117
|
+
readonly run: (
|
|
118
|
+
ctx: SyncRequestContext,
|
|
119
|
+
clientId: string,
|
|
120
|
+
requestId: string,
|
|
121
|
+
params: unknown,
|
|
122
|
+
) => Promise<RemoteOperationResponse>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type RegisteredRemoteOperation =
|
|
126
|
+
| RegisteredRemoteQuery
|
|
127
|
+
| RegisteredRemoteCommand;
|
|
128
|
+
|
|
129
|
+
type RemoteQueryAccess<Params> =
|
|
130
|
+
| { readonly access: 'scoped' }
|
|
131
|
+
| {
|
|
132
|
+
readonly access: 'privileged';
|
|
133
|
+
readonly authorize: (
|
|
134
|
+
context: RemoteOperationAuthContext,
|
|
135
|
+
params: Params,
|
|
136
|
+
) => boolean | Promise<boolean>;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export interface RemoteQueryOptions<Params> {
|
|
140
|
+
readonly maxRows: number;
|
|
141
|
+
readonly auth: RemoteQueryAccess<Params>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scopeAllowed(
|
|
145
|
+
allowed: ScopeMap,
|
|
146
|
+
variable: string,
|
|
147
|
+
value: string,
|
|
148
|
+
): boolean {
|
|
149
|
+
const values = allowed[variable];
|
|
150
|
+
return (
|
|
151
|
+
values !== undefined && (values.includes('*') || values.includes(value))
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeQueryRows(
|
|
156
|
+
rows: readonly Readonly<Record<string, unknown>>[],
|
|
157
|
+
columns: AuthoritativeQueryDescriptor<unknown>['resultColumns'],
|
|
158
|
+
): readonly Readonly<Record<string, unknown>>[] {
|
|
159
|
+
return rows.map((row) => {
|
|
160
|
+
const normalized: Record<string, unknown> = Object.create(null);
|
|
161
|
+
for (const column of columns) {
|
|
162
|
+
const value = row[column.name];
|
|
163
|
+
if (value === undefined || (value === null && !column.nullable)) {
|
|
164
|
+
throw syncError(
|
|
165
|
+
'operation.query_failed',
|
|
166
|
+
'registered query returned a missing or invalid null value',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (value === null) {
|
|
170
|
+
normalized[column.name] = null;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
switch (column.type) {
|
|
174
|
+
case 'integer': {
|
|
175
|
+
const integer =
|
|
176
|
+
typeof value === 'bigint'
|
|
177
|
+
? Number(value)
|
|
178
|
+
: typeof value === 'string' && /^-?(?:0|[1-9][0-9]*)$/.test(value)
|
|
179
|
+
? Number(value)
|
|
180
|
+
: value;
|
|
181
|
+
if (typeof integer !== 'number' || !Number.isSafeInteger(integer)) {
|
|
182
|
+
throw syncError(
|
|
183
|
+
'operation.query_failed',
|
|
184
|
+
'registered query returned an invalid integer',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
normalized[column.name] = integer;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'float':
|
|
191
|
+
{
|
|
192
|
+
const float =
|
|
193
|
+
typeof value === 'string' &&
|
|
194
|
+
/^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/.test(
|
|
195
|
+
value,
|
|
196
|
+
)
|
|
197
|
+
? Number(value)
|
|
198
|
+
: value;
|
|
199
|
+
if (typeof float !== 'number' || !Number.isFinite(float)) {
|
|
200
|
+
throw syncError(
|
|
201
|
+
'operation.query_failed',
|
|
202
|
+
'registered query returned an invalid float',
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
normalized[column.name] = float;
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
case 'boolean':
|
|
209
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
210
|
+
normalized[column.name] = value !== 0;
|
|
211
|
+
} else if (typeof value !== 'boolean') {
|
|
212
|
+
throw syncError(
|
|
213
|
+
'operation.query_failed',
|
|
214
|
+
'registered query returned an invalid boolean',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
case 'json': {
|
|
219
|
+
const json =
|
|
220
|
+
typeof value === 'string' ? value : JSON.stringify(value);
|
|
221
|
+
if (json === undefined) {
|
|
222
|
+
throw syncError(
|
|
223
|
+
'operation.query_failed',
|
|
224
|
+
'registered query returned invalid JSON',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
JSON.parse(json);
|
|
228
|
+
normalized[column.name] = json;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'bytes':
|
|
232
|
+
case 'crdt': {
|
|
233
|
+
const bytes =
|
|
234
|
+
value instanceof Uint8Array
|
|
235
|
+
? value
|
|
236
|
+
: value instanceof ArrayBuffer
|
|
237
|
+
? new Uint8Array(value)
|
|
238
|
+
: Array.isArray(value) &&
|
|
239
|
+
value.every(
|
|
240
|
+
(entry) =>
|
|
241
|
+
typeof entry === 'number' &&
|
|
242
|
+
Number.isInteger(entry) &&
|
|
243
|
+
entry >= 0 &&
|
|
244
|
+
entry <= 255,
|
|
245
|
+
)
|
|
246
|
+
? new Uint8Array(value)
|
|
247
|
+
: undefined;
|
|
248
|
+
if (bytes === undefined) {
|
|
249
|
+
throw syncError(
|
|
250
|
+
'operation.query_failed',
|
|
251
|
+
'registered query returned invalid bytes',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
normalized[column.name] = bytes;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case 'string':
|
|
258
|
+
case 'blob_ref':
|
|
259
|
+
if (typeof value !== 'string') {
|
|
260
|
+
throw syncError(
|
|
261
|
+
'operation.query_failed',
|
|
262
|
+
'registered query returned an invalid string',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (!(column.name in normalized)) normalized[column.name] = value;
|
|
268
|
+
}
|
|
269
|
+
return normalized;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Register one generated named query as an authoritative remote operation. */
|
|
274
|
+
export function registerRemoteQuery<Params>(
|
|
275
|
+
descriptor: AuthoritativeQueryDescriptor<Params>,
|
|
276
|
+
options: RemoteQueryOptions<Params>,
|
|
277
|
+
): RegisteredRemoteQuery {
|
|
278
|
+
if (
|
|
279
|
+
descriptor.id.length === 0 ||
|
|
280
|
+
new Set(descriptor.tables).size !== descriptor.tables.length ||
|
|
281
|
+
!Array.isArray(descriptor.resultColumns) ||
|
|
282
|
+
descriptor.resultColumns.length === 0 ||
|
|
283
|
+
new Set(descriptor.resultColumns.map((column) => column.name)).size !==
|
|
284
|
+
descriptor.resultColumns.length
|
|
285
|
+
) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
'remote query requires a non-empty id and unique tables and result columns',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
!Number.isSafeInteger(options.maxRows) ||
|
|
292
|
+
options.maxRows < 1 ||
|
|
293
|
+
options.maxRows > 10_000
|
|
294
|
+
) {
|
|
295
|
+
throw new Error('remote query maxRows must be an integer from 1 to 10,000');
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
kind: 'query',
|
|
299
|
+
id: descriptor.id,
|
|
300
|
+
tables: descriptor.tables,
|
|
301
|
+
run: async (ctx, clientId, rawParams) => {
|
|
302
|
+
const params = rawParams as Params;
|
|
303
|
+
const schema = compileSchema(ctx.schema);
|
|
304
|
+
if (options.auth.access === 'scoped') {
|
|
305
|
+
const allowed = await ctx.resolveScopes({
|
|
306
|
+
partition: ctx.partition,
|
|
307
|
+
actorId: ctx.actorId,
|
|
308
|
+
clientId,
|
|
309
|
+
});
|
|
310
|
+
if (allowed === RESOLVER_OUTAGE) {
|
|
311
|
+
throw syncError(
|
|
312
|
+
'operation.forbidden',
|
|
313
|
+
'live scope authorization is unavailable for this query',
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const coverage = descriptor.coverage(params);
|
|
317
|
+
const coverageByTable = new Map<string, RemoteQueryCoverage>();
|
|
318
|
+
for (const entry of coverage) {
|
|
319
|
+
if (
|
|
320
|
+
coverageByTable.has(entry.base.table) ||
|
|
321
|
+
!descriptor.tables.includes(entry.base.table)
|
|
322
|
+
) {
|
|
323
|
+
throw syncError(
|
|
324
|
+
'operation.invalid_request',
|
|
325
|
+
'scoped remote query has invalid generated scope coverage',
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
coverageByTable.set(entry.base.table, entry);
|
|
329
|
+
}
|
|
330
|
+
if (descriptor.tables.some((table) => !coverageByTable.has(table))) {
|
|
331
|
+
throw syncError(
|
|
332
|
+
'operation.invalid_request',
|
|
333
|
+
'scoped remote query lacks complete generated scope coverage',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
for (const entry of coverage) {
|
|
337
|
+
const table = schema.tables.get(entry.base.table);
|
|
338
|
+
const fixedScopes = entry.base.fixedScopes ?? {};
|
|
339
|
+
const coveredVariables = new Set([
|
|
340
|
+
entry.base.variable,
|
|
341
|
+
...Object.keys(fixedScopes),
|
|
342
|
+
]);
|
|
343
|
+
if (
|
|
344
|
+
table === undefined ||
|
|
345
|
+
Object.prototype.hasOwnProperty.call(
|
|
346
|
+
fixedScopes,
|
|
347
|
+
entry.base.variable,
|
|
348
|
+
) ||
|
|
349
|
+
coveredVariables.size !== table.declaredVariables.size ||
|
|
350
|
+
[...table.declaredVariables].some(
|
|
351
|
+
(variable) => !coveredVariables.has(variable),
|
|
352
|
+
)
|
|
353
|
+
) {
|
|
354
|
+
throw syncError(
|
|
355
|
+
'operation.invalid_request',
|
|
356
|
+
'scoped remote query lacks complete generated scope coverage',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (entry.units.length === 0) {
|
|
360
|
+
throw syncError(
|
|
361
|
+
'operation.invalid_request',
|
|
362
|
+
'scoped remote query has an empty scope unit',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
for (const value of entry.units) {
|
|
366
|
+
if (!scopeAllowed(allowed, entry.base.variable, value)) {
|
|
367
|
+
throw syncError('operation.forbidden');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const [variable, values] of Object.entries(fixedScopes)) {
|
|
371
|
+
if (
|
|
372
|
+
values.length === 0 ||
|
|
373
|
+
values.some((value) => !scopeAllowed(allowed, variable, value))
|
|
374
|
+
) {
|
|
375
|
+
throw syncError('operation.forbidden');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} else if (
|
|
380
|
+
!(await options.auth.authorize(
|
|
381
|
+
{ actorId: ctx.actorId, partition: ctx.partition, clientId },
|
|
382
|
+
params,
|
|
383
|
+
))
|
|
384
|
+
) {
|
|
385
|
+
throw syncError('operation.forbidden');
|
|
386
|
+
}
|
|
387
|
+
if (ctx.storage.queryAuthoritative === undefined) {
|
|
388
|
+
throw syncError(
|
|
389
|
+
'operation.storage_unsupported',
|
|
390
|
+
'configured storage does not implement authoritative queries',
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
await ctx.storage.ensureSchema(schema);
|
|
394
|
+
const selectedSql = descriptor.sqlFor?.(params) ?? descriptor.sql;
|
|
395
|
+
let result;
|
|
396
|
+
try {
|
|
397
|
+
result = await ctx.storage.queryAuthoritative(ctx.partition, {
|
|
398
|
+
sql: `SELECT * FROM (${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
|
|
399
|
+
params: [...descriptor.bind(params), options.maxRows + 1],
|
|
400
|
+
tables: descriptor.tables,
|
|
401
|
+
});
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof SyncError) throw error;
|
|
404
|
+
throw syncError(
|
|
405
|
+
'operation.query_failed',
|
|
406
|
+
'registered query execution failed',
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (result.rows.length > options.maxRows) {
|
|
410
|
+
throw syncError('operation.result_too_large');
|
|
411
|
+
}
|
|
412
|
+
let rows;
|
|
413
|
+
try {
|
|
414
|
+
rows = normalizeQueryRows(result.rows, descriptor.resultColumns);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error instanceof SyncError) throw error;
|
|
417
|
+
throw syncError(
|
|
418
|
+
'operation.query_failed',
|
|
419
|
+
'registered query result decoding failed',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
revision: 1,
|
|
424
|
+
kind: 'query',
|
|
425
|
+
operationId: descriptor.id,
|
|
426
|
+
rows,
|
|
427
|
+
maxCommitSeq: result.maxCommitSeq,
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A typed client/server identity for a server-authoritative command. */
|
|
434
|
+
export function remoteCommand<Input = undefined>(
|
|
435
|
+
id: string,
|
|
436
|
+
): RemoteCommandDescriptor<Input> {
|
|
437
|
+
if (id.length === 0) throw new Error('remote command id must be non-empty');
|
|
438
|
+
return { id };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function commandOperations(
|
|
442
|
+
mutations: readonly CommandMutation[],
|
|
443
|
+
schema: ReturnType<typeof compileSchema>,
|
|
444
|
+
): PushOperation[] {
|
|
445
|
+
if (mutations.length === 0) {
|
|
446
|
+
throw syncError(
|
|
447
|
+
'operation.invalid_request',
|
|
448
|
+
'authoritative command produced no mutations',
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return mutations.map((mutation) => {
|
|
452
|
+
const table = schema.tables.get(mutation.table);
|
|
453
|
+
if (table === undefined) {
|
|
454
|
+
throw syncError(
|
|
455
|
+
'operation.invalid_request',
|
|
456
|
+
'command targets an unknown table',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (mutation.op === 'delete') {
|
|
460
|
+
if (mutation.rowId.length === 0) {
|
|
461
|
+
throw syncError(
|
|
462
|
+
'operation.invalid_request',
|
|
463
|
+
'command delete rowId is empty',
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return { table: table.name, rowId: mutation.rowId, op: 'delete' };
|
|
467
|
+
}
|
|
468
|
+
const supplied = new Set(Object.keys(mutation.values));
|
|
469
|
+
for (const name of supplied) {
|
|
470
|
+
if (!table.columnIndex.has(name)) {
|
|
471
|
+
throw syncError(
|
|
472
|
+
'operation.invalid_request',
|
|
473
|
+
'command upsert has an unknown column',
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const values = table.columns.map((column) => {
|
|
478
|
+
if (!supplied.has(column.name)) {
|
|
479
|
+
throw syncError(
|
|
480
|
+
'operation.invalid_request',
|
|
481
|
+
'command upsert must provide a full row',
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return mutation.values[column.name] ?? null;
|
|
485
|
+
});
|
|
486
|
+
const rowId = values[table.primaryKeyIndex];
|
|
487
|
+
if (typeof rowId !== 'string' || rowId.length === 0) {
|
|
488
|
+
throw syncError(
|
|
489
|
+
'operation.invalid_request',
|
|
490
|
+
'command upsert requires a non-empty string primary key',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
table: table.name,
|
|
495
|
+
rowId,
|
|
496
|
+
op: 'upsert',
|
|
497
|
+
payload: encodeRow(table.columns, values),
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function commandContext(
|
|
503
|
+
ctx: SyncRequestContext,
|
|
504
|
+
tx: StorageTransaction,
|
|
505
|
+
resolved: ResolvedScopes,
|
|
506
|
+
schema: ReturnType<typeof compileSchema>,
|
|
507
|
+
clientId: string,
|
|
508
|
+
operationId: string,
|
|
509
|
+
requestId: string,
|
|
510
|
+
): RemoteCommandContext {
|
|
511
|
+
return {
|
|
512
|
+
actorId: ctx.actorId,
|
|
513
|
+
partition: ctx.partition,
|
|
514
|
+
clientId,
|
|
515
|
+
operationId,
|
|
516
|
+
requestId,
|
|
517
|
+
getRow: async (tableName, rowId) => {
|
|
518
|
+
const table = schema.tables.get(tableName);
|
|
519
|
+
if (table === undefined) {
|
|
520
|
+
throw syncError(
|
|
521
|
+
'operation.invalid_request',
|
|
522
|
+
'command read targets an unknown table',
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
const stored = await tx.getRow(tableName, rowId);
|
|
526
|
+
if (
|
|
527
|
+
stored === undefined ||
|
|
528
|
+
!authorizeWrite(table, stored.scopes, resolved)
|
|
529
|
+
) {
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
return toValidateRow(
|
|
533
|
+
table.columns,
|
|
534
|
+
decodeRow(table.columns, stored.payload),
|
|
535
|
+
);
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Register custom command code that plans one ordinary Syncular commit. */
|
|
541
|
+
export function registerRemoteCommand<Input>(
|
|
542
|
+
descriptor: RemoteCommandDescriptor<Input>,
|
|
543
|
+
options: RemoteCommandOptions<Input>,
|
|
544
|
+
): RegisteredRemoteCommand {
|
|
545
|
+
if (descriptor.id.length === 0) {
|
|
546
|
+
throw new Error('remote command id must be non-empty');
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
kind: 'command',
|
|
550
|
+
id: descriptor.id,
|
|
551
|
+
run: async (ctx, clientId, requestId, rawInput) => {
|
|
552
|
+
const input = rawInput as Input;
|
|
553
|
+
if (
|
|
554
|
+
!(await options.authorize(
|
|
555
|
+
{ actorId: ctx.actorId, partition: ctx.partition, clientId },
|
|
556
|
+
input,
|
|
557
|
+
))
|
|
558
|
+
) {
|
|
559
|
+
throw syncError('operation.forbidden');
|
|
560
|
+
}
|
|
561
|
+
const allowed = await ctx.resolveScopes({
|
|
562
|
+
partition: ctx.partition,
|
|
563
|
+
actorId: ctx.actorId,
|
|
564
|
+
clientId,
|
|
565
|
+
});
|
|
566
|
+
if (allowed === RESOLVER_OUTAGE) {
|
|
567
|
+
throw syncError(
|
|
568
|
+
'operation.forbidden',
|
|
569
|
+
'live scope authorization is unavailable for this command',
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const resolved: ResolvedScopes = { ok: true, allowed };
|
|
573
|
+
const schema = compileSchema(ctx.schema);
|
|
574
|
+
await ctx.storage.ensureSchema(schema);
|
|
575
|
+
const processed = await processPushOperationsWithTrace(
|
|
576
|
+
ctx,
|
|
577
|
+
schema,
|
|
578
|
+
resolved,
|
|
579
|
+
JSON.stringify(['remote-command', ctx.actorId, clientId]),
|
|
580
|
+
JSON.stringify([descriptor.id, requestId]),
|
|
581
|
+
async (tx) =>
|
|
582
|
+
commandOperations(
|
|
583
|
+
await options.run(
|
|
584
|
+
commandContext(
|
|
585
|
+
ctx,
|
|
586
|
+
tx,
|
|
587
|
+
resolved,
|
|
588
|
+
schema,
|
|
589
|
+
clientId,
|
|
590
|
+
descriptor.id,
|
|
591
|
+
requestId,
|
|
592
|
+
),
|
|
593
|
+
input,
|
|
594
|
+
),
|
|
595
|
+
schema,
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
return {
|
|
599
|
+
revision: 1,
|
|
600
|
+
kind: 'command',
|
|
601
|
+
operationId: descriptor.id,
|
|
602
|
+
requestId,
|
|
603
|
+
status: processed.frame.status,
|
|
604
|
+
...(processed.frame.commitSeq !== undefined
|
|
605
|
+
? { commitSeq: processed.frame.commitSeq }
|
|
606
|
+
: {}),
|
|
607
|
+
results: processed.frame.results,
|
|
608
|
+
};
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export class RemoteOperationRegistry {
|
|
614
|
+
readonly #operations = new Map<string, RegisteredRemoteOperation>();
|
|
615
|
+
|
|
616
|
+
constructor(operations: readonly RegisteredRemoteOperation[]) {
|
|
617
|
+
for (const operation of operations) {
|
|
618
|
+
if (operation.id.length === 0 || this.#operations.has(operation.id)) {
|
|
619
|
+
throw new Error('remote operation ids must be non-empty and unique');
|
|
620
|
+
}
|
|
621
|
+
this.#operations.set(operation.id, operation);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
get(id: string): RegisteredRemoteOperation | undefined {
|
|
626
|
+
return this.#operations.get(id);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function encodeRemoteOperationError(
|
|
631
|
+
error: unknown,
|
|
632
|
+
fallbackCode: 'operation.invalid_request' | 'operation.execution_failed',
|
|
633
|
+
): Uint8Array {
|
|
634
|
+
const sync =
|
|
635
|
+
error instanceof SyncError
|
|
636
|
+
? error
|
|
637
|
+
: syncError(
|
|
638
|
+
fallbackCode,
|
|
639
|
+
fallbackCode === 'operation.invalid_request'
|
|
640
|
+
? 'invalid remote operation request'
|
|
641
|
+
: 'registered remote operation failed',
|
|
642
|
+
);
|
|
643
|
+
return encodeRemoteOperationResponse({
|
|
644
|
+
revision: 1,
|
|
645
|
+
kind: 'error',
|
|
646
|
+
code: sync.code,
|
|
647
|
+
message: sync.message,
|
|
648
|
+
retryable: sync.retryable,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export async function handleRemoteOperation(
|
|
653
|
+
bytes: Uint8Array,
|
|
654
|
+
ctx: SyncRequestContext,
|
|
655
|
+
registry: RemoteOperationRegistry,
|
|
656
|
+
): Promise<Uint8Array> {
|
|
657
|
+
let request;
|
|
658
|
+
try {
|
|
659
|
+
request = decodeRemoteOperationRequest(bytes);
|
|
660
|
+
if (
|
|
661
|
+
typeof request !== 'object' ||
|
|
662
|
+
request === null ||
|
|
663
|
+
request.revision !== 1 ||
|
|
664
|
+
(request.kind !== 'query' && request.kind !== 'command') ||
|
|
665
|
+
typeof request.clientId !== 'string' ||
|
|
666
|
+
typeof request.operationId !== 'string' ||
|
|
667
|
+
request.clientId.length === 0 ||
|
|
668
|
+
request.operationId.length === 0
|
|
669
|
+
) {
|
|
670
|
+
throw syncError('operation.invalid_request');
|
|
671
|
+
}
|
|
672
|
+
} catch (error) {
|
|
673
|
+
return encodeRemoteOperationError(error, 'operation.invalid_request');
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
677
|
+
throw syncError(
|
|
678
|
+
'sync.invalid_client_id',
|
|
679
|
+
'clientId uses a reserved server-command namespace (§1.5)',
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const clientRecord = await ctx.storage.getClientRecord(
|
|
683
|
+
ctx.partition,
|
|
684
|
+
request.clientId,
|
|
685
|
+
);
|
|
686
|
+
if (clientRecord !== undefined && clientRecord.actorId !== ctx.actorId) {
|
|
687
|
+
throw syncError(
|
|
688
|
+
'sync.invalid_client_id',
|
|
689
|
+
'clientId is bound to a different actor in this partition (§1.5)',
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (
|
|
693
|
+
request.kind === 'command' &&
|
|
694
|
+
(typeof request.requestId !== 'string' || request.requestId.length === 0)
|
|
695
|
+
) {
|
|
696
|
+
throw syncError('operation.invalid_request');
|
|
697
|
+
}
|
|
698
|
+
const operation = registry.get(request.operationId);
|
|
699
|
+
if (operation === undefined) {
|
|
700
|
+
throw syncError('operation.unknown');
|
|
701
|
+
}
|
|
702
|
+
if (request.kind === 'query') {
|
|
703
|
+
if (operation.kind !== 'query') throw syncError('operation.unknown');
|
|
704
|
+
return encodeRemoteOperationResponse(
|
|
705
|
+
await operation.run(ctx, request.clientId, request.params),
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (operation.kind !== 'command') throw syncError('operation.unknown');
|
|
709
|
+
return encodeRemoteOperationResponse(
|
|
710
|
+
await operation.run(
|
|
711
|
+
ctx,
|
|
712
|
+
request.clientId,
|
|
713
|
+
request.requestId,
|
|
714
|
+
request.params,
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
return encodeRemoteOperationError(error, 'operation.execution_failed');
|
|
719
|
+
}
|
|
720
|
+
}
|