@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.
Files changed (51) hide show
  1. package/README.md +134 -4
  2. package/dist/admin.d.ts +10 -4
  3. package/dist/admin.js +10 -0
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/context.d.ts +9 -0
  7. package/dist/context.js +2 -0
  8. package/dist/d1-storage.d.ts +10 -1
  9. package/dist/d1-storage.js +216 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.js +43 -1
  12. package/dist/events.d.ts +52 -3
  13. package/dist/handler.js +4 -1
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +4 -0
  16. package/dist/operations-realtime.d.ts +16 -0
  17. package/dist/operations-realtime.js +196 -0
  18. package/dist/operations.d.ts +97 -0
  19. package/dist/operations.js +392 -0
  20. package/dist/postgres-storage.d.ts +11 -2
  21. package/dist/postgres-storage.js +220 -0
  22. package/dist/push.d.ts +8 -2
  23. package/dist/push.js +75 -21
  24. package/dist/reactions.d.ts +167 -0
  25. package/dist/reactions.js +442 -0
  26. package/dist/realtime.js +4 -1
  27. package/dist/sqlite-dialect.d.ts +1 -1
  28. package/dist/sqlite-dialect.js +20 -0
  29. package/dist/sqlite-storage.d.ts +10 -1
  30. package/dist/sqlite-storage.js +215 -0
  31. package/dist/storage.d.ts +109 -0
  32. package/dist/validate.js +1 -0
  33. package/package.json +2 -2
  34. package/src/admin.ts +27 -3
  35. package/src/authoritative-query.ts +218 -0
  36. package/src/context.ts +10 -0
  37. package/src/d1-storage.ts +352 -0
  38. package/src/errors.ts +43 -1
  39. package/src/events.ts +64 -2
  40. package/src/handler.ts +13 -1
  41. package/src/index.ts +32 -0
  42. package/src/operations-realtime.ts +272 -0
  43. package/src/operations.ts +720 -0
  44. package/src/postgres-storage.ts +351 -0
  45. package/src/push.ts +97 -29
  46. package/src/reactions.ts +741 -0
  47. package/src/realtime.ts +7 -1
  48. package/src/sqlite-dialect.ts +20 -0
  49. package/src/sqlite-storage.ts +365 -0
  50. package/src/storage.ts +165 -0
  51. package/src/validate.ts +1 -0
@@ -0,0 +1,97 @@
1
+ import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
2
+ import type { SyncRequestContext } from './context.js';
3
+ import type { AuthoritativeQueryValue } from './storage.js';
4
+ import { type ValidateRow } from './validate.js';
5
+ export interface RemoteQueryDependency {
6
+ readonly table: string;
7
+ readonly scopeKeys?: readonly string[];
8
+ }
9
+ export interface RemoteQueryCoverage {
10
+ readonly base: {
11
+ readonly table: string;
12
+ readonly variable: string;
13
+ readonly fixedScopes?: Readonly<Record<string, readonly string[]>>;
14
+ };
15
+ readonly units: readonly string[];
16
+ }
17
+ /** Structural subset implemented by generated NamedQuery descriptors. */
18
+ export interface AuthoritativeQueryDescriptor<Params = undefined> {
19
+ readonly id: string;
20
+ readonly hasParams: boolean;
21
+ readonly sql: string;
22
+ readonly tables: readonly string[];
23
+ readonly resultColumns: readonly {
24
+ readonly name: string;
25
+ readonly type: 'string' | 'integer' | 'float' | 'boolean' | 'json' | 'bytes' | 'blob_ref' | 'crdt';
26
+ readonly nullable: boolean;
27
+ }[];
28
+ readonly bind: (params: Params) => readonly AuthoritativeQueryValue[];
29
+ readonly sqlFor?: (params: Params) => string;
30
+ readonly dependencies: (params: Params) => readonly RemoteQueryDependency[];
31
+ readonly coverage: (params: Params) => readonly RemoteQueryCoverage[];
32
+ }
33
+ export interface RemoteOperationAuthContext {
34
+ readonly actorId: string;
35
+ readonly partition: string;
36
+ readonly clientId: string;
37
+ }
38
+ export type RegisteredRemoteQuery = {
39
+ readonly kind: 'query';
40
+ readonly id: string;
41
+ readonly tables: readonly string[];
42
+ readonly run: (ctx: SyncRequestContext, clientId: string, params: unknown) => Promise<RemoteOperationResponse>;
43
+ };
44
+ export interface RemoteCommandDescriptor<Input = undefined> {
45
+ readonly id: string;
46
+ readonly __input?: Input;
47
+ }
48
+ export type CommandMutation = {
49
+ readonly table: string;
50
+ readonly op: 'upsert';
51
+ readonly values: Readonly<Record<string, RowValue>>;
52
+ } | {
53
+ readonly table: string;
54
+ readonly op: 'delete';
55
+ readonly rowId: string;
56
+ };
57
+ export interface RemoteCommandContext {
58
+ readonly actorId: string;
59
+ readonly partition: string;
60
+ readonly clientId: string;
61
+ readonly operationId: string;
62
+ readonly requestId: string;
63
+ getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
64
+ }
65
+ export interface RemoteCommandOptions<Input> {
66
+ readonly authorize: (context: RemoteOperationAuthContext, input: Input) => boolean | Promise<boolean>;
67
+ readonly run: (context: RemoteCommandContext, input: Input) => readonly CommandMutation[] | Promise<readonly CommandMutation[]>;
68
+ }
69
+ export type RegisteredRemoteCommand = {
70
+ readonly kind: 'command';
71
+ readonly id: string;
72
+ readonly run: (ctx: SyncRequestContext, clientId: string, requestId: string, params: unknown) => Promise<RemoteOperationResponse>;
73
+ };
74
+ export type RegisteredRemoteOperation = RegisteredRemoteQuery | RegisteredRemoteCommand;
75
+ type RemoteQueryAccess<Params> = {
76
+ readonly access: 'scoped';
77
+ } | {
78
+ readonly access: 'privileged';
79
+ readonly authorize: (context: RemoteOperationAuthContext, params: Params) => boolean | Promise<boolean>;
80
+ };
81
+ export interface RemoteQueryOptions<Params> {
82
+ readonly maxRows: number;
83
+ readonly auth: RemoteQueryAccess<Params>;
84
+ }
85
+ /** Register one generated named query as an authoritative remote operation. */
86
+ export declare function registerRemoteQuery<Params>(descriptor: AuthoritativeQueryDescriptor<Params>, options: RemoteQueryOptions<Params>): RegisteredRemoteQuery;
87
+ /** A typed client/server identity for a server-authoritative command. */
88
+ export declare function remoteCommand<Input = undefined>(id: string): RemoteCommandDescriptor<Input>;
89
+ /** Register custom command code that plans one ordinary Syncular commit. */
90
+ export declare function registerRemoteCommand<Input>(descriptor: RemoteCommandDescriptor<Input>, options: RemoteCommandOptions<Input>): RegisteredRemoteCommand;
91
+ export declare class RemoteOperationRegistry {
92
+ #private;
93
+ constructor(operations: readonly RegisteredRemoteOperation[]);
94
+ get(id: string): RegisteredRemoteOperation | undefined;
95
+ }
96
+ export declare function handleRemoteOperation(bytes: Uint8Array, ctx: SyncRequestContext, registry: RemoteOperationRegistry): Promise<Uint8Array>;
97
+ export {};
@@ -0,0 +1,392 @@
1
+ import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
2
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
3
+ import { SyncError, syncError } from './errors.js';
4
+ import { processPushOperationsWithTrace } from './push.js';
5
+ import { compileSchema } from './schema.js';
6
+ import { authorizeWrite } from './scopes.js';
7
+ import { toValidateRow } from './validate.js';
8
+ function scopeAllowed(allowed, variable, value) {
9
+ const values = allowed[variable];
10
+ return (values !== undefined && (values.includes('*') || values.includes(value)));
11
+ }
12
+ function normalizeQueryRows(rows, columns) {
13
+ return rows.map((row) => {
14
+ const normalized = Object.create(null);
15
+ for (const column of columns) {
16
+ const value = row[column.name];
17
+ if (value === undefined || (value === null && !column.nullable)) {
18
+ throw syncError('operation.query_failed', 'registered query returned a missing or invalid null value');
19
+ }
20
+ if (value === null) {
21
+ normalized[column.name] = null;
22
+ continue;
23
+ }
24
+ switch (column.type) {
25
+ case 'integer': {
26
+ const integer = typeof value === 'bigint'
27
+ ? Number(value)
28
+ : typeof value === 'string' && /^-?(?:0|[1-9][0-9]*)$/.test(value)
29
+ ? Number(value)
30
+ : value;
31
+ if (typeof integer !== 'number' || !Number.isSafeInteger(integer)) {
32
+ throw syncError('operation.query_failed', 'registered query returned an invalid integer');
33
+ }
34
+ normalized[column.name] = integer;
35
+ break;
36
+ }
37
+ case 'float':
38
+ {
39
+ const float = typeof value === 'string' &&
40
+ /^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/.test(value)
41
+ ? Number(value)
42
+ : value;
43
+ if (typeof float !== 'number' || !Number.isFinite(float)) {
44
+ throw syncError('operation.query_failed', 'registered query returned an invalid float');
45
+ }
46
+ normalized[column.name] = float;
47
+ }
48
+ break;
49
+ case 'boolean':
50
+ if (typeof value === 'number' && Number.isFinite(value)) {
51
+ normalized[column.name] = value !== 0;
52
+ }
53
+ else if (typeof value !== 'boolean') {
54
+ throw syncError('operation.query_failed', 'registered query returned an invalid boolean');
55
+ }
56
+ break;
57
+ case 'json': {
58
+ const json = typeof value === 'string' ? value : JSON.stringify(value);
59
+ if (json === undefined) {
60
+ throw syncError('operation.query_failed', 'registered query returned invalid JSON');
61
+ }
62
+ JSON.parse(json);
63
+ normalized[column.name] = json;
64
+ break;
65
+ }
66
+ case 'bytes':
67
+ case 'crdt': {
68
+ const bytes = value instanceof Uint8Array
69
+ ? value
70
+ : value instanceof ArrayBuffer
71
+ ? new Uint8Array(value)
72
+ : Array.isArray(value) &&
73
+ value.every((entry) => typeof entry === 'number' &&
74
+ Number.isInteger(entry) &&
75
+ entry >= 0 &&
76
+ entry <= 255)
77
+ ? new Uint8Array(value)
78
+ : undefined;
79
+ if (bytes === undefined) {
80
+ throw syncError('operation.query_failed', 'registered query returned invalid bytes');
81
+ }
82
+ normalized[column.name] = bytes;
83
+ break;
84
+ }
85
+ case 'string':
86
+ case 'blob_ref':
87
+ if (typeof value !== 'string') {
88
+ throw syncError('operation.query_failed', 'registered query returned an invalid string');
89
+ }
90
+ break;
91
+ }
92
+ if (!(column.name in normalized))
93
+ normalized[column.name] = value;
94
+ }
95
+ return normalized;
96
+ });
97
+ }
98
+ /** Register one generated named query as an authoritative remote operation. */
99
+ export function registerRemoteQuery(descriptor, options) {
100
+ if (descriptor.id.length === 0 ||
101
+ new Set(descriptor.tables).size !== descriptor.tables.length ||
102
+ !Array.isArray(descriptor.resultColumns) ||
103
+ descriptor.resultColumns.length === 0 ||
104
+ new Set(descriptor.resultColumns.map((column) => column.name)).size !==
105
+ descriptor.resultColumns.length) {
106
+ throw new Error('remote query requires a non-empty id and unique tables and result columns');
107
+ }
108
+ if (!Number.isSafeInteger(options.maxRows) ||
109
+ options.maxRows < 1 ||
110
+ options.maxRows > 10_000) {
111
+ throw new Error('remote query maxRows must be an integer from 1 to 10,000');
112
+ }
113
+ return {
114
+ kind: 'query',
115
+ id: descriptor.id,
116
+ tables: descriptor.tables,
117
+ run: async (ctx, clientId, rawParams) => {
118
+ const params = rawParams;
119
+ const schema = compileSchema(ctx.schema);
120
+ if (options.auth.access === 'scoped') {
121
+ const allowed = await ctx.resolveScopes({
122
+ partition: ctx.partition,
123
+ actorId: ctx.actorId,
124
+ clientId,
125
+ });
126
+ if (allowed === RESOLVER_OUTAGE) {
127
+ throw syncError('operation.forbidden', 'live scope authorization is unavailable for this query');
128
+ }
129
+ const coverage = descriptor.coverage(params);
130
+ const coverageByTable = new Map();
131
+ for (const entry of coverage) {
132
+ if (coverageByTable.has(entry.base.table) ||
133
+ !descriptor.tables.includes(entry.base.table)) {
134
+ throw syncError('operation.invalid_request', 'scoped remote query has invalid generated scope coverage');
135
+ }
136
+ coverageByTable.set(entry.base.table, entry);
137
+ }
138
+ if (descriptor.tables.some((table) => !coverageByTable.has(table))) {
139
+ throw syncError('operation.invalid_request', 'scoped remote query lacks complete generated scope coverage');
140
+ }
141
+ for (const entry of coverage) {
142
+ const table = schema.tables.get(entry.base.table);
143
+ const fixedScopes = entry.base.fixedScopes ?? {};
144
+ const coveredVariables = new Set([
145
+ entry.base.variable,
146
+ ...Object.keys(fixedScopes),
147
+ ]);
148
+ if (table === undefined ||
149
+ Object.prototype.hasOwnProperty.call(fixedScopes, entry.base.variable) ||
150
+ coveredVariables.size !== table.declaredVariables.size ||
151
+ [...table.declaredVariables].some((variable) => !coveredVariables.has(variable))) {
152
+ throw syncError('operation.invalid_request', 'scoped remote query lacks complete generated scope coverage');
153
+ }
154
+ if (entry.units.length === 0) {
155
+ throw syncError('operation.invalid_request', 'scoped remote query has an empty scope unit');
156
+ }
157
+ for (const value of entry.units) {
158
+ if (!scopeAllowed(allowed, entry.base.variable, value)) {
159
+ throw syncError('operation.forbidden');
160
+ }
161
+ }
162
+ for (const [variable, values] of Object.entries(fixedScopes)) {
163
+ if (values.length === 0 ||
164
+ values.some((value) => !scopeAllowed(allowed, variable, value))) {
165
+ throw syncError('operation.forbidden');
166
+ }
167
+ }
168
+ }
169
+ }
170
+ else if (!(await options.auth.authorize({ actorId: ctx.actorId, partition: ctx.partition, clientId }, params))) {
171
+ throw syncError('operation.forbidden');
172
+ }
173
+ if (ctx.storage.queryAuthoritative === undefined) {
174
+ throw syncError('operation.storage_unsupported', 'configured storage does not implement authoritative queries');
175
+ }
176
+ await ctx.storage.ensureSchema(schema);
177
+ const selectedSql = descriptor.sqlFor?.(params) ?? descriptor.sql;
178
+ let result;
179
+ try {
180
+ result = await ctx.storage.queryAuthoritative(ctx.partition, {
181
+ sql: `SELECT * FROM (${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
182
+ params: [...descriptor.bind(params), options.maxRows + 1],
183
+ tables: descriptor.tables,
184
+ });
185
+ }
186
+ catch (error) {
187
+ if (error instanceof SyncError)
188
+ throw error;
189
+ throw syncError('operation.query_failed', 'registered query execution failed');
190
+ }
191
+ if (result.rows.length > options.maxRows) {
192
+ throw syncError('operation.result_too_large');
193
+ }
194
+ let rows;
195
+ try {
196
+ rows = normalizeQueryRows(result.rows, descriptor.resultColumns);
197
+ }
198
+ catch (error) {
199
+ if (error instanceof SyncError)
200
+ throw error;
201
+ throw syncError('operation.query_failed', 'registered query result decoding failed');
202
+ }
203
+ return {
204
+ revision: 1,
205
+ kind: 'query',
206
+ operationId: descriptor.id,
207
+ rows,
208
+ maxCommitSeq: result.maxCommitSeq,
209
+ };
210
+ },
211
+ };
212
+ }
213
+ /** A typed client/server identity for a server-authoritative command. */
214
+ export function remoteCommand(id) {
215
+ if (id.length === 0)
216
+ throw new Error('remote command id must be non-empty');
217
+ return { id };
218
+ }
219
+ function commandOperations(mutations, schema) {
220
+ if (mutations.length === 0) {
221
+ throw syncError('operation.invalid_request', 'authoritative command produced no mutations');
222
+ }
223
+ return mutations.map((mutation) => {
224
+ const table = schema.tables.get(mutation.table);
225
+ if (table === undefined) {
226
+ throw syncError('operation.invalid_request', 'command targets an unknown table');
227
+ }
228
+ if (mutation.op === 'delete') {
229
+ if (mutation.rowId.length === 0) {
230
+ throw syncError('operation.invalid_request', 'command delete rowId is empty');
231
+ }
232
+ return { table: table.name, rowId: mutation.rowId, op: 'delete' };
233
+ }
234
+ const supplied = new Set(Object.keys(mutation.values));
235
+ for (const name of supplied) {
236
+ if (!table.columnIndex.has(name)) {
237
+ throw syncError('operation.invalid_request', 'command upsert has an unknown column');
238
+ }
239
+ }
240
+ const values = table.columns.map((column) => {
241
+ if (!supplied.has(column.name)) {
242
+ throw syncError('operation.invalid_request', 'command upsert must provide a full row');
243
+ }
244
+ return mutation.values[column.name] ?? null;
245
+ });
246
+ const rowId = values[table.primaryKeyIndex];
247
+ if (typeof rowId !== 'string' || rowId.length === 0) {
248
+ throw syncError('operation.invalid_request', 'command upsert requires a non-empty string primary key');
249
+ }
250
+ return {
251
+ table: table.name,
252
+ rowId,
253
+ op: 'upsert',
254
+ payload: encodeRow(table.columns, values),
255
+ };
256
+ });
257
+ }
258
+ function commandContext(ctx, tx, resolved, schema, clientId, operationId, requestId) {
259
+ return {
260
+ actorId: ctx.actorId,
261
+ partition: ctx.partition,
262
+ clientId,
263
+ operationId,
264
+ requestId,
265
+ getRow: async (tableName, rowId) => {
266
+ const table = schema.tables.get(tableName);
267
+ if (table === undefined) {
268
+ throw syncError('operation.invalid_request', 'command read targets an unknown table');
269
+ }
270
+ const stored = await tx.getRow(tableName, rowId);
271
+ if (stored === undefined ||
272
+ !authorizeWrite(table, stored.scopes, resolved)) {
273
+ return undefined;
274
+ }
275
+ return toValidateRow(table.columns, decodeRow(table.columns, stored.payload));
276
+ },
277
+ };
278
+ }
279
+ /** Register custom command code that plans one ordinary Syncular commit. */
280
+ export function registerRemoteCommand(descriptor, options) {
281
+ if (descriptor.id.length === 0) {
282
+ throw new Error('remote command id must be non-empty');
283
+ }
284
+ return {
285
+ kind: 'command',
286
+ id: descriptor.id,
287
+ run: async (ctx, clientId, requestId, rawInput) => {
288
+ const input = rawInput;
289
+ if (!(await options.authorize({ actorId: ctx.actorId, partition: ctx.partition, clientId }, input))) {
290
+ throw syncError('operation.forbidden');
291
+ }
292
+ const allowed = await ctx.resolveScopes({
293
+ partition: ctx.partition,
294
+ actorId: ctx.actorId,
295
+ clientId,
296
+ });
297
+ if (allowed === RESOLVER_OUTAGE) {
298
+ throw syncError('operation.forbidden', 'live scope authorization is unavailable for this command');
299
+ }
300
+ const resolved = { ok: true, allowed };
301
+ const schema = compileSchema(ctx.schema);
302
+ await ctx.storage.ensureSchema(schema);
303
+ const processed = await processPushOperationsWithTrace(ctx, schema, resolved, JSON.stringify(['remote-command', ctx.actorId, clientId]), JSON.stringify([descriptor.id, requestId]), async (tx) => commandOperations(await options.run(commandContext(ctx, tx, resolved, schema, clientId, descriptor.id, requestId), input), schema));
304
+ return {
305
+ revision: 1,
306
+ kind: 'command',
307
+ operationId: descriptor.id,
308
+ requestId,
309
+ status: processed.frame.status,
310
+ ...(processed.frame.commitSeq !== undefined
311
+ ? { commitSeq: processed.frame.commitSeq }
312
+ : {}),
313
+ results: processed.frame.results,
314
+ };
315
+ },
316
+ };
317
+ }
318
+ export class RemoteOperationRegistry {
319
+ #operations = new Map();
320
+ constructor(operations) {
321
+ for (const operation of operations) {
322
+ if (operation.id.length === 0 || this.#operations.has(operation.id)) {
323
+ throw new Error('remote operation ids must be non-empty and unique');
324
+ }
325
+ this.#operations.set(operation.id, operation);
326
+ }
327
+ }
328
+ get(id) {
329
+ return this.#operations.get(id);
330
+ }
331
+ }
332
+ function encodeRemoteOperationError(error, fallbackCode) {
333
+ const sync = error instanceof SyncError
334
+ ? error
335
+ : syncError(fallbackCode, fallbackCode === 'operation.invalid_request'
336
+ ? 'invalid remote operation request'
337
+ : 'registered remote operation failed');
338
+ return encodeRemoteOperationResponse({
339
+ revision: 1,
340
+ kind: 'error',
341
+ code: sync.code,
342
+ message: sync.message,
343
+ retryable: sync.retryable,
344
+ });
345
+ }
346
+ export async function handleRemoteOperation(bytes, ctx, registry) {
347
+ let request;
348
+ try {
349
+ request = decodeRemoteOperationRequest(bytes);
350
+ if (typeof request !== 'object' ||
351
+ request === null ||
352
+ request.revision !== 1 ||
353
+ (request.kind !== 'query' && request.kind !== 'command') ||
354
+ typeof request.clientId !== 'string' ||
355
+ typeof request.operationId !== 'string' ||
356
+ request.clientId.length === 0 ||
357
+ request.operationId.length === 0) {
358
+ throw syncError('operation.invalid_request');
359
+ }
360
+ }
361
+ catch (error) {
362
+ return encodeRemoteOperationError(error, 'operation.invalid_request');
363
+ }
364
+ try {
365
+ if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
366
+ throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
367
+ }
368
+ const clientRecord = await ctx.storage.getClientRecord(ctx.partition, request.clientId);
369
+ if (clientRecord !== undefined && clientRecord.actorId !== ctx.actorId) {
370
+ throw syncError('sync.invalid_client_id', 'clientId is bound to a different actor in this partition (§1.5)');
371
+ }
372
+ if (request.kind === 'command' &&
373
+ (typeof request.requestId !== 'string' || request.requestId.length === 0)) {
374
+ throw syncError('operation.invalid_request');
375
+ }
376
+ const operation = registry.get(request.operationId);
377
+ if (operation === undefined) {
378
+ throw syncError('operation.unknown');
379
+ }
380
+ if (request.kind === 'query') {
381
+ if (operation.kind !== 'query')
382
+ throw syncError('operation.unknown');
383
+ return encodeRemoteOperationResponse(await operation.run(ctx, request.clientId, request.params));
384
+ }
385
+ if (operation.kind !== 'command')
386
+ throw syncError('operation.unknown');
387
+ return encodeRemoteOperationResponse(await operation.run(ctx, request.clientId, request.requestId, request.params));
388
+ }
389
+ catch (error) {
390
+ return encodeRemoteOperationError(error, 'operation.execution_failed');
391
+ }
392
+ }
@@ -1,6 +1,6 @@
1
1
  import { type PgExecutor } from './pg-executor.js';
2
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
- import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
3
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
4
4
  /**
5
5
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
6
6
  *
@@ -23,7 +23,7 @@ import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuer
23
23
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
24
24
  * .test.ts` asserts an `Index` node here too.
25
25
  */
26
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
26
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
27
27
  export declare class PostgresServerStorage implements ServerStorage {
28
28
  #private;
29
29
  constructor(exec: PgExecutor);
@@ -42,12 +42,21 @@ export declare class PostgresServerStorage implements ServerStorage {
42
42
  */
43
43
  begin(partition: string): Promise<StorageTransaction>;
44
44
  getMaxCommitSeq(partition: string): Promise<number>;
45
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
45
46
  getHorizonSeq(partition: string): Promise<number>;
46
47
  setHorizonSeq(partition: string, seq: number): Promise<void>;
47
48
  pruneCommitsThrough(partition: string, seq: number): Promise<number>;
48
49
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
49
50
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
50
51
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
52
+ claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
53
+ completeReaction(partition: string, idempotencyKey: string, leaseOwner: string, completedAtMs: number): Promise<boolean>;
54
+ extendReactionLease(partition: string, idempotencyKey: string, leaseOwner: string, leaseExpiresAtMs: number): Promise<boolean>;
55
+ failReaction(partition: string, idempotencyKey: string, update: ReactionFailureUpdate): Promise<boolean>;
56
+ retryReaction(partition: string, idempotencyKey: string, nowMs: number): Promise<boolean>;
57
+ getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
58
+ listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
59
+ pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
51
60
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
52
61
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
53
62
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;