@syncular/server 0.26.5 → 0.27.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/dist/content-encoding.d.ts +17 -0
- package/dist/content-encoding.js +22 -0
- package/dist/context.d.ts +9 -0
- package/dist/d1-storage.d.ts +46 -0
- package/dist/d1-storage.js +12 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +29 -0
- package/dist/handler.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/operations-realtime.js +4 -1
- package/dist/operations.js +6 -1
- package/dist/postgres-storage.d.ts +42 -0
- package/dist/postgres-storage.js +12 -1
- package/dist/pull.d.ts +46 -6
- package/dist/pull.js +129 -34
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +32 -26
- package/dist/relational-rows.d.ts +13 -0
- package/dist/relational-rows.js +25 -0
- package/dist/s3-segment-store.d.ts +3 -1
- package/dist/s3-segment-store.js +16 -0
- package/dist/segment-download.d.ts +16 -0
- package/dist/segment-download.js +71 -21
- package/dist/segment-store.d.ts +16 -0
- package/dist/segment-store.js +19 -0
- package/dist/sqlite-storage.d.ts +5 -0
- package/dist/sqlite-storage.js +18 -1
- package/dist/storage.d.ts +12 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +35 -0
- package/src/context.ts +9 -0
- package/src/d1-storage.ts +20 -1
- package/src/errors.ts +56 -0
- package/src/handler.ts +2 -0
- package/src/index.ts +1 -0
- package/src/operations-realtime.ts +4 -1
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +22 -1
- package/src/pull.ts +212 -39
- package/src/realtime.ts +45 -32
- package/src/relational-rows.ts +29 -0
- package/src/s3-segment-store.ts +15 -0
- package/src/segment-download.ts +129 -21
- package/src/segment-store.ts +36 -0
- package/src/sqlite-storage.ts +27 -0
- package/src/storage.ts +13 -0
package/dist/segment-store.js
CHANGED
|
@@ -146,6 +146,15 @@ export function mergeSegmentRecord(existing, metadata, segmentId, byteLength, no
|
|
|
146
146
|
expiresAtMs: Math.max(existing?.expiresAtMs ?? nowMs + ttlMs, nowMs + ttlMs),
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
|
+
/** One buffered segment as a single-chunk `SegmentStream` body. */
|
|
150
|
+
export function bytesStream(bytes) {
|
|
151
|
+
return new ReadableStream({
|
|
152
|
+
start(controller) {
|
|
153
|
+
controller.enqueue(new Uint8Array(bytes));
|
|
154
|
+
controller.close();
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
149
158
|
export async function segmentIdFor(bytes) {
|
|
150
159
|
return `sha256:${await sha256Hex(bytes)}`;
|
|
151
160
|
}
|
|
@@ -168,6 +177,16 @@ export class MemorySegmentStore {
|
|
|
168
177
|
async get(segmentId) {
|
|
169
178
|
return this.#entries.get(segmentId);
|
|
170
179
|
}
|
|
180
|
+
async open(segmentId) {
|
|
181
|
+
const entry = this.#entries.get(segmentId);
|
|
182
|
+
if (entry === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
return {
|
|
185
|
+
record: entry.record,
|
|
186
|
+
body: bytesStream(entry.bytes),
|
|
187
|
+
byteLength: entry.bytes.length,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
171
190
|
async find(key, nowMs) {
|
|
172
191
|
for (const { record } of this.#entries.values()) {
|
|
173
192
|
const publication = selectSegmentPublication(record, {
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
2
|
+
import type { ScopeMap } from '@syncular/core';
|
|
2
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
4
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
4
5
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
@@ -47,6 +48,10 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
47
48
|
getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
|
|
48
49
|
listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
|
|
49
50
|
pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
|
|
51
|
+
latestScopedChangeSeq(partition: string, query: {
|
|
52
|
+
readonly table: string;
|
|
53
|
+
readonly scopeFilter: ScopeMap;
|
|
54
|
+
}): Promise<number>;
|
|
50
55
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
51
56
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
52
57
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -12,7 +12,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
12
12
|
*/
|
|
13
13
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
14
14
|
import { syncError } from './errors.js';
|
|
15
|
-
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
15
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
16
16
|
import { matchesEffective } from './scopes.js';
|
|
17
17
|
import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
18
18
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
@@ -1009,6 +1009,23 @@ END`);
|
|
|
1009
1009
|
};
|
|
1010
1010
|
});
|
|
1011
1011
|
}
|
|
1012
|
+
async latestScopedChangeSeq(partition, query) {
|
|
1013
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
1014
|
+
const values = firstVariable === undefined
|
|
1015
|
+
? []
|
|
1016
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
1017
|
+
if (firstVariable === undefined || values.length === 0)
|
|
1018
|
+
return 0;
|
|
1019
|
+
const row = this.db
|
|
1020
|
+
.query(latestScopedChangeSeqSql(values.length))
|
|
1021
|
+
.get(...values.flatMap((value) => [
|
|
1022
|
+
partition,
|
|
1023
|
+
query.table,
|
|
1024
|
+
firstVariable,
|
|
1025
|
+
value,
|
|
1026
|
+
]));
|
|
1027
|
+
return row?.seq ?? 0;
|
|
1028
|
+
}
|
|
1012
1029
|
async readCommitWindow(partition, query) {
|
|
1013
1030
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
1014
1031
|
const firstVariable = variables[0];
|
package/dist/storage.d.ts
CHANGED
|
@@ -589,6 +589,18 @@ export interface ServerStorage {
|
|
|
589
589
|
* reach `limitChanges` or the window is exhausted.
|
|
590
590
|
*/
|
|
591
591
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
592
|
+
/**
|
|
593
|
+
* Newest commitSeq whose change to `query.table` carries a value of the
|
|
594
|
+
* filter's first variable (keys sorted) in the inverted change-scope index:
|
|
595
|
+
* the `readCommitWindow` candidate rule, so no change a window read would
|
|
596
|
+
* deliver for this filter sits above it. 0 when the index holds none (never
|
|
597
|
+
* written, or pruned). The §4.7 sqlite-lane bootstrap pin reads it; it MUST
|
|
598
|
+
* be an index lookup per value, independent of the table's history.
|
|
599
|
+
*/
|
|
600
|
+
latestScopedChangeSeq(partition: string, query: {
|
|
601
|
+
readonly table: string;
|
|
602
|
+
readonly scopeFilter: ScopeMap;
|
|
603
|
+
}): Promise<number>;
|
|
592
604
|
/** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
|
|
593
605
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
594
606
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.27.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/content-encoding.ts
CHANGED
|
@@ -92,3 +92,38 @@ export function encodeSegmentBody(
|
|
|
92
92
|
}
|
|
93
93
|
return { bytes };
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Segments above this size are relayed as a stream instead of being
|
|
98
|
+
* buffered and compressed in memory: a sqlite image can exceed the memory of
|
|
99
|
+
* a Workers isolate (128 MB).
|
|
100
|
+
*/
|
|
101
|
+
export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
|
|
102
|
+
|
|
103
|
+
export interface EncodedSegmentStream {
|
|
104
|
+
readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
105
|
+
/** Absent ⇒ identity (no `Content-Encoding` header). */
|
|
106
|
+
readonly contentEncoding?: 'gzip';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compress a streamed segment body per §5.8 with the runtime's
|
|
111
|
+
* `CompressionStream`: gzip when the client accepts it, identity otherwise
|
|
112
|
+
* (zstd has no standard streaming codec).
|
|
113
|
+
*/
|
|
114
|
+
export function encodeSegmentStream(
|
|
115
|
+
body: ReadableStream<Uint8Array<ArrayBuffer>>,
|
|
116
|
+
acceptEncoding: string | undefined,
|
|
117
|
+
): EncodedSegmentStream {
|
|
118
|
+
if (
|
|
119
|
+
acceptEncoding === undefined ||
|
|
120
|
+
!accepts(acceptEncoding, 'gzip') ||
|
|
121
|
+
typeof CompressionStream !== 'function'
|
|
122
|
+
) {
|
|
123
|
+
return { body };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
body: body.pipeThrough(new CompressionStream('gzip')),
|
|
127
|
+
contentEncoding: 'gzip',
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { BlobStore } from './blob-store';
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger';
|
|
10
|
+
import type { SyncularErrorHandler } from './errors';
|
|
10
11
|
import type { SyncularServerEvents } from './events';
|
|
11
12
|
import type { LeaseStore } from './lease-store';
|
|
12
13
|
import type { AnyReactionPlanner } from './reactions';
|
|
@@ -192,6 +193,14 @@ export interface SyncServerConfig {
|
|
|
192
193
|
* event objects are built. A throwing sink never affects processing.
|
|
193
194
|
*/
|
|
194
195
|
readonly events?: SyncularServerEvents;
|
|
196
|
+
/**
|
|
197
|
+
* Host error reporting (§10.2 `sync.internal_error`). Receives the original
|
|
198
|
+
* exception whenever a request, socket round, segment or blob download,
|
|
199
|
+
* or remote operation fails with something other than a `SyncError`; the
|
|
200
|
+
* client sees only the catalog code. Like the events sink, a throwing
|
|
201
|
+
* handler never changes the response.
|
|
202
|
+
*/
|
|
203
|
+
readonly onError?: SyncularErrorHandler;
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
/** Per-request context: the config plus host-authenticated identity. */
|
package/src/d1-storage.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
42
42
|
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
43
43
|
* rather than a lock D1 does not expose.
|
|
44
44
|
*/
|
|
45
|
-
import { decodeRow, type RowValue } from '@syncular/core';
|
|
45
|
+
import { decodeRow, type RowValue, type ScopeMap } from '@syncular/core';
|
|
46
46
|
import {
|
|
47
47
|
bindAuthoritativePartition,
|
|
48
48
|
type BoundAuthoritativeQuery,
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
assertPhysicalColumns,
|
|
55
55
|
assertStoredLayouts,
|
|
56
56
|
commitWindowPageSql,
|
|
57
|
+
latestScopedChangeSeqSql,
|
|
57
58
|
deleteRowSql,
|
|
58
59
|
deleteSqliteRowScopesSql,
|
|
59
60
|
dropTableDdl,
|
|
@@ -2146,6 +2147,24 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2146
2147
|
};
|
|
2147
2148
|
}
|
|
2148
2149
|
|
|
2150
|
+
async latestScopedChangeSeq(
|
|
2151
|
+
partition: string,
|
|
2152
|
+
query: { readonly table: string; readonly scopeFilter: ScopeMap },
|
|
2153
|
+
): Promise<number> {
|
|
2154
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
2155
|
+
const values =
|
|
2156
|
+
firstVariable === undefined
|
|
2157
|
+
? []
|
|
2158
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
2159
|
+
if (firstVariable === undefined || values.length === 0) return 0;
|
|
2160
|
+
// Concurrent reads of a pull share one `db.batch` round trip.
|
|
2161
|
+
const [row] = await this.#batchedAll<{ seq: number | null }>(
|
|
2162
|
+
latestScopedChangeSeqSql(values.length),
|
|
2163
|
+
values.flatMap((value) => [partition, query.table, firstVariable, value]),
|
|
2164
|
+
);
|
|
2165
|
+
return row?.seq ?? 0;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2149
2168
|
async readCommitWindow(
|
|
2150
2169
|
partition: string,
|
|
2151
2170
|
query: CommitWindowQuery,
|
package/src/errors.ts
CHANGED
|
@@ -171,6 +171,14 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
171
171
|
// checkpoint is incomplete or the database is newer than the running build.
|
|
172
172
|
// Formerly host-only; promoted to the §10.2 catalogue so an in-flight
|
|
173
173
|
// request can be refused with a distinguishable identity.
|
|
174
|
+
// An exception outside this catalog (host code, storage, network) reached
|
|
175
|
+
// an adapter; the original goes to `onError`, never onto the wire.
|
|
176
|
+
'sync.internal_error': {
|
|
177
|
+
category: 'internal',
|
|
178
|
+
retryable: true,
|
|
179
|
+
recommendedAction: 'retryLater',
|
|
180
|
+
httpStatus: 500,
|
|
181
|
+
},
|
|
174
182
|
'sync.schema_not_ready': {
|
|
175
183
|
category: 'internal',
|
|
176
184
|
retryable: true,
|
|
@@ -310,3 +318,51 @@ export function errorBody(error: SyncError): {
|
|
|
310
318
|
: {}),
|
|
311
319
|
};
|
|
312
320
|
}
|
|
321
|
+
|
|
322
|
+
/** The adapter surface that caught an exception reported through `onError`. */
|
|
323
|
+
export type SyncularErrorRoute =
|
|
324
|
+
| 'sync'
|
|
325
|
+
| 'operations'
|
|
326
|
+
| 'segments'
|
|
327
|
+
| 'blobs'
|
|
328
|
+
| 'admin'
|
|
329
|
+
| 'realtime';
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Host error reporting: receives every exception that is not a `SyncError`
|
|
333
|
+
* before the adapter answers `sync.internal_error` (or the remote-operation
|
|
334
|
+
* `operation.*` failure code), with the surface that caught it.
|
|
335
|
+
*/
|
|
336
|
+
export type SyncularErrorHandler = (
|
|
337
|
+
error: unknown,
|
|
338
|
+
context: { readonly route: SyncularErrorRoute },
|
|
339
|
+
) => void;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
343
|
+
* passes through unchanged. Anything else goes to `onError` and becomes
|
|
344
|
+
* `sync.internal_error` (§10.2), whose message never carries the exception
|
|
345
|
+
* text.
|
|
346
|
+
*/
|
|
347
|
+
export function adapterSyncError(
|
|
348
|
+
error: unknown,
|
|
349
|
+
onError: SyncularErrorHandler | undefined,
|
|
350
|
+
route: SyncularErrorRoute,
|
|
351
|
+
): SyncError {
|
|
352
|
+
if (error instanceof SyncError) return error;
|
|
353
|
+
reportError(error, onError, route);
|
|
354
|
+
return new SyncError('sync.internal_error', 'internal server error');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Hand `error` to the host's `onError`; a throwing handler is contained. */
|
|
358
|
+
export function reportError(
|
|
359
|
+
error: unknown,
|
|
360
|
+
onError: SyncularErrorHandler | undefined,
|
|
361
|
+
route: SyncularErrorRoute,
|
|
362
|
+
): void {
|
|
363
|
+
try {
|
|
364
|
+
onError?.(error, { route });
|
|
365
|
+
} catch {
|
|
366
|
+
// The report is best effort; the response stays the catalog error.
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/handler.ts
CHANGED
|
@@ -545,10 +545,12 @@ async function* streamResponse(
|
|
|
545
545
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
546
546
|
const prepared = await prepareSections(
|
|
547
547
|
ctx,
|
|
548
|
+
schema,
|
|
548
549
|
limits,
|
|
549
550
|
plan.subscriptions,
|
|
550
551
|
maxSeq,
|
|
551
552
|
horizonSeq,
|
|
553
|
+
plan.logEpoch,
|
|
552
554
|
);
|
|
553
555
|
for (const [index, subscription] of plan.subscriptions.entries()) {
|
|
554
556
|
const trace: PullSectionTrace | undefined =
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from '@syncular/core';
|
|
6
6
|
import type { RealtimeNotifier, SyncRequestContext } from './context';
|
|
7
7
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context';
|
|
8
|
-
import { SyncError, syncError } from './errors';
|
|
8
|
+
import { reportError, SyncError, syncError } from './errors';
|
|
9
9
|
import type {
|
|
10
10
|
RegisteredRemoteQuery,
|
|
11
11
|
RemoteOperationRegistry,
|
|
@@ -181,6 +181,9 @@ class WatchSession implements RemoteOperationWatchSession {
|
|
|
181
181
|
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
|
+
if (!(error instanceof SyncError)) {
|
|
185
|
+
reportError(error, this.#ctx.onError, 'operations');
|
|
186
|
+
}
|
|
184
187
|
this.#sendError(
|
|
185
188
|
state.watchId,
|
|
186
189
|
error instanceof SyncError
|
package/src/operations.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
RESOLVER_OUTAGE,
|
|
19
19
|
touchAuthenticatedPartition,
|
|
20
20
|
} from './context';
|
|
21
|
-
import { SyncError, syncError } from './errors';
|
|
21
|
+
import { reportError, SyncError, syncError } from './errors';
|
|
22
22
|
import { processPushOperationsWithTrace } from './push';
|
|
23
23
|
import { compileSchema } from './schema';
|
|
24
24
|
import { authorizeWrite, type ResolvedScopes } from './scopes';
|
|
@@ -450,6 +450,7 @@ export function registerRemoteQuery<Params>(
|
|
|
450
450
|
);
|
|
451
451
|
} catch (error) {
|
|
452
452
|
if (error instanceof SyncError) throw error;
|
|
453
|
+
reportError(error, ctx.onError, 'operations');
|
|
453
454
|
throw syncError(
|
|
454
455
|
'operation.query_failed',
|
|
455
456
|
'registered query execution failed',
|
|
@@ -463,6 +464,7 @@ export function registerRemoteQuery<Params>(
|
|
|
463
464
|
rows = normalizeQueryRows(result.rows, descriptor.resultColumns);
|
|
464
465
|
} catch (error) {
|
|
465
466
|
if (error instanceof SyncError) throw error;
|
|
467
|
+
reportError(error, ctx.onError, 'operations');
|
|
466
468
|
throw syncError(
|
|
467
469
|
'operation.query_failed',
|
|
468
470
|
'registered query result decoding failed',
|
|
@@ -766,6 +768,9 @@ export async function handleRemoteOperation(
|
|
|
766
768
|
),
|
|
767
769
|
);
|
|
768
770
|
} catch (error) {
|
|
771
|
+
if (!(error instanceof SyncError)) {
|
|
772
|
+
reportError(error, ctx.onError, 'operations');
|
|
773
|
+
}
|
|
769
774
|
return encodeRemoteOperationError(error, 'operation.execution_failed');
|
|
770
775
|
}
|
|
771
776
|
}
|
package/src/postgres-storage.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { serveNotReadyError } from './readiness';
|
|
|
40
40
|
* would leave gaps on rollback, which the pull-window arithmetic in §4.5
|
|
41
41
|
* does not tolerate). Cross-partition pushes never contend.
|
|
42
42
|
*/
|
|
43
|
-
import type { PushOperationResult } from '@syncular/core';
|
|
43
|
+
import type { PushOperationResult, ScopeMap } from '@syncular/core';
|
|
44
44
|
import {
|
|
45
45
|
bindAuthoritativePartition,
|
|
46
46
|
type BoundAuthoritativeQuery,
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
assertPhysicalColumns,
|
|
60
60
|
assertStoredLayouts,
|
|
61
61
|
COMMIT_WINDOW_BATCH_SQL,
|
|
62
|
+
LATEST_SCOPED_CHANGE_BATCH_SQL,
|
|
62
63
|
deleteRowSql,
|
|
63
64
|
dropTableDdl,
|
|
64
65
|
indexRowPageStatement,
|
|
@@ -2206,6 +2207,26 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2206
2207
|
};
|
|
2207
2208
|
}
|
|
2208
2209
|
|
|
2210
|
+
async latestScopedChangeSeq(
|
|
2211
|
+
partition: string,
|
|
2212
|
+
query: { readonly table: string; readonly scopeFilter: ScopeMap },
|
|
2213
|
+
): Promise<number> {
|
|
2214
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
2215
|
+
const values =
|
|
2216
|
+
firstVariable === undefined
|
|
2217
|
+
? []
|
|
2218
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
2219
|
+
if (firstVariable === undefined || values.length === 0) return 0;
|
|
2220
|
+
// Concurrent reads of one table in a pull share one statement.
|
|
2221
|
+
const [row] = await this.#batchedPage<{ seq: unknown }>(
|
|
2222
|
+
LATEST_SCOPED_CHANGE_BATCH_SQL,
|
|
2223
|
+
partition,
|
|
2224
|
+
query.table,
|
|
2225
|
+
{ var: firstVariable, vals: values },
|
|
2226
|
+
);
|
|
2227
|
+
return row === undefined ? 0 : asNumber(row.seq);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2209
2230
|
async readCommitWindow(
|
|
2210
2231
|
partition: string,
|
|
2211
2232
|
query: CommitWindowQuery,
|