@syncular/server 0.26.4 → 0.26.6
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 +110 -45
- package/dist/pull.d.ts +46 -6
- package/dist/pull.js +129 -34
- package/dist/push.js +19 -2
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +32 -26
- package/dist/relational-rows.d.ts +19 -0
- package/dist/relational-rows.js +38 -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 +20 -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 +145 -60
- package/src/pull.ts +212 -39
- package/src/push.ts +18 -2
- package/src/realtime.ts +45 -32
- package/src/relational-rows.ts +43 -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 +21 -0
|
@@ -25,3 +25,20 @@ export interface EncodedSegmentBody {
|
|
|
25
25
|
* tiny, or the runtime has no codec.
|
|
26
26
|
*/
|
|
27
27
|
export declare function encodeSegmentBody(bytes: Uint8Array, acceptEncoding: string | undefined): EncodedSegmentBody;
|
|
28
|
+
/**
|
|
29
|
+
* Segments above this size are relayed as a stream instead of being
|
|
30
|
+
* buffered and compressed in memory: a sqlite image can exceed the memory of
|
|
31
|
+
* a Workers isolate (128 MB).
|
|
32
|
+
*/
|
|
33
|
+
export declare const SEGMENT_STREAM_THRESHOLD_BYTES: number;
|
|
34
|
+
export interface EncodedSegmentStream {
|
|
35
|
+
readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
36
|
+
/** Absent ⇒ identity (no `Content-Encoding` header). */
|
|
37
|
+
readonly contentEncoding?: 'gzip';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compress a streamed segment body per §5.8 with the runtime's
|
|
41
|
+
* `CompressionStream`: gzip when the client accepts it, identity otherwise
|
|
42
|
+
* (zstd has no standard streaming codec).
|
|
43
|
+
*/
|
|
44
|
+
export declare function encodeSegmentStream(body: ReadableStream<Uint8Array<ArrayBuffer>>, acceptEncoding: string | undefined): EncodedSegmentStream;
|
package/dist/content-encoding.js
CHANGED
|
@@ -67,3 +67,25 @@ export function encodeSegmentBody(bytes, acceptEncoding) {
|
|
|
67
67
|
}
|
|
68
68
|
return { bytes };
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Segments above this size are relayed as a stream instead of being
|
|
72
|
+
* buffered and compressed in memory: a sqlite image can exceed the memory of
|
|
73
|
+
* a Workers isolate (128 MB).
|
|
74
|
+
*/
|
|
75
|
+
export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
|
|
76
|
+
/**
|
|
77
|
+
* Compress a streamed segment body per §5.8 with the runtime's
|
|
78
|
+
* `CompressionStream`: gzip when the client accepts it, identity otherwise
|
|
79
|
+
* (zstd has no standard streaming codec).
|
|
80
|
+
*/
|
|
81
|
+
export function encodeSegmentStream(body, acceptEncoding) {
|
|
82
|
+
if (acceptEncoding === undefined ||
|
|
83
|
+
!accepts(acceptEncoding, 'gzip') ||
|
|
84
|
+
typeof CompressionStream !== 'function') {
|
|
85
|
+
return { body };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
body: body.pipeThrough(new CompressionStream('gzip')),
|
|
89
|
+
contentEncoding: 'gzip',
|
|
90
|
+
};
|
|
91
|
+
}
|
package/dist/context.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { BlobStore } from './blob-store.js';
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger.js';
|
|
10
|
+
import type { SyncularErrorHandler } from './errors.js';
|
|
10
11
|
import type { SyncularServerEvents } from './events.js';
|
|
11
12
|
import type { LeaseStore } from './lease-store.js';
|
|
12
13
|
import type { AnyReactionPlanner } from './reactions.js';
|
|
@@ -167,6 +168,14 @@ export interface SyncServerConfig {
|
|
|
167
168
|
* event objects are built. A throwing sink never affects processing.
|
|
168
169
|
*/
|
|
169
170
|
readonly events?: SyncularServerEvents;
|
|
171
|
+
/**
|
|
172
|
+
* Host error reporting (§10.2 `sync.internal_error`). Receives the original
|
|
173
|
+
* exception whenever a request, socket round, segment or blob download,
|
|
174
|
+
* or remote operation fails with something other than a `SyncError`; the
|
|
175
|
+
* client sees only the catalog code. Like the events sink, a throwing
|
|
176
|
+
* handler never changes the response.
|
|
177
|
+
*/
|
|
178
|
+
readonly onError?: SyncularErrorHandler;
|
|
170
179
|
}
|
|
171
180
|
/** Per-request context: the config plus host-authenticated identity. */
|
|
172
181
|
export interface SyncRequestContext extends SyncServerConfig {
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare D1 server storage for Workers deployments.
|
|
4
|
+
*
|
|
5
|
+
* D1 *is* SQLite exposed over an async, statement-at-a-time API
|
|
6
|
+
* (`prepare(sql).bind(...).all()` / `.first()` / `.run()`, plus `batch([…])`
|
|
7
|
+
* for atomic multi-statement writes). So this storage shares the schema and
|
|
8
|
+
* value codecs with `SqliteServerStorage` via `sqlite-dialect.ts` — same
|
|
9
|
+
* DDL, same `?` placeholders, same JSON-scope / base64-push-result
|
|
10
|
+
* serialization — and differs only in the execution shape (async, no
|
|
11
|
+
* interactive transaction).
|
|
12
|
+
*
|
|
13
|
+
* ## Transaction model (the one real divergence)
|
|
14
|
+
*
|
|
15
|
+
* D1 has **no interactive transaction**: you cannot `BEGIN`, run
|
|
16
|
+
* conditional reads and writes on a pinned connection, then `COMMIT`. The
|
|
17
|
+
* only atomic primitive is `db.batch([stmt, …])` — a set of statements
|
|
18
|
+
* applied in one implicit transaction, all-or-nothing.
|
|
19
|
+
*
|
|
20
|
+
* The push handler (§6) reads first (conflict detection via `getRow`) and
|
|
21
|
+
* only then writes (`upsertRow`/`deleteRow`/`appendCommit`/
|
|
22
|
+
* `putPushResult`). `D1Transaction` exploits exactly that ordering:
|
|
23
|
+
*
|
|
24
|
+
* - reads (`getRow`) execute immediately against D1 (autocommit);
|
|
25
|
+
* - writes are **buffered** as prepared statements;
|
|
26
|
+
* - `commit()` flushes the whole buffer as one `db.batch(...)` — the same
|
|
27
|
+
* atomicity §6.4 requires (a rejected op rolls back by simply never
|
|
28
|
+
* flushing);
|
|
29
|
+
* - `rollback()` drops the buffer unsent.
|
|
30
|
+
*
|
|
31
|
+
* `appendCommit` allocates the dense per-partition `commitSeq` (§2.1) by
|
|
32
|
+
* reading `max_commit_seq` live and buffering the `+1` write. Under a single
|
|
33
|
+
* Worker request this is exact. Two concurrent pushes to one partition need an
|
|
34
|
+
* external serialization point (normally a per-partition Durable Object); a
|
|
35
|
+
* realtime notifier alone does not serialize HTTP writes. Every deployment
|
|
36
|
+
* that accepts D1 pushes MUST front same-partition sync rounds with a
|
|
37
|
+
* coordinating primitive (a DO or a Queue). The adapter fails closed unless
|
|
38
|
+
* that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
|
|
39
|
+
* provide the required pre-operation lock.
|
|
40
|
+
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
41
|
+
* rather than a lock D1 does not expose.
|
|
42
|
+
*/
|
|
43
|
+
import { type ScopeMap } from '@syncular/core';
|
|
2
44
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
45
|
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';
|
|
4
46
|
export interface D1PreparedStatement {
|
|
@@ -73,6 +115,10 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
73
115
|
getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
|
|
74
116
|
listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
|
|
75
117
|
pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
|
|
118
|
+
latestScopedChangeSeq(partition: string, query: {
|
|
119
|
+
readonly table: string;
|
|
120
|
+
readonly scopeFilter: ScopeMap;
|
|
121
|
+
}): Promise<number>;
|
|
76
122
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
77
123
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
78
124
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -45,7 +45,7 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
45
45
|
import { decodeRow } from '@syncular/core';
|
|
46
46
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
47
47
|
import { syncError } from './errors.js';
|
|
48
|
-
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
48
|
+
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
49
|
import { matchesEffective } from './scopes.js';
|
|
50
50
|
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
@@ -1439,6 +1439,17 @@ export class D1ServerStorage {
|
|
|
1439
1439
|
deadLetter: results.filter((row) => row.status === 'dead-letter').length,
|
|
1440
1440
|
};
|
|
1441
1441
|
}
|
|
1442
|
+
async latestScopedChangeSeq(partition, query) {
|
|
1443
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
1444
|
+
const values = firstVariable === undefined
|
|
1445
|
+
? []
|
|
1446
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
1447
|
+
if (firstVariable === undefined || values.length === 0)
|
|
1448
|
+
return 0;
|
|
1449
|
+
// Concurrent reads of a pull share one `db.batch` round trip.
|
|
1450
|
+
const [row] = await this.#batchedAll(latestScopedChangeSeqSql(values.length), values.flatMap((value) => [partition, query.table, firstVariable, value]));
|
|
1451
|
+
return row?.seq ?? 0;
|
|
1452
|
+
}
|
|
1442
1453
|
async readCommitWindow(partition, query) {
|
|
1443
1454
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
1444
1455
|
const firstVariable = variables[0];
|
package/dist/errors.d.ts
CHANGED
|
@@ -34,3 +34,22 @@ export declare function errorBody(error: SyncError): {
|
|
|
34
34
|
message: string;
|
|
35
35
|
details?: unknown;
|
|
36
36
|
};
|
|
37
|
+
/** The adapter surface that caught an exception reported through `onError`. */
|
|
38
|
+
export type SyncularErrorRoute = 'sync' | 'operations' | 'segments' | 'blobs' | 'admin' | 'realtime';
|
|
39
|
+
/**
|
|
40
|
+
* Host error reporting: receives every exception that is not a `SyncError`
|
|
41
|
+
* before the adapter answers `sync.internal_error` (or the remote-operation
|
|
42
|
+
* `operation.*` failure code), with the surface that caught it.
|
|
43
|
+
*/
|
|
44
|
+
export type SyncularErrorHandler = (error: unknown, context: {
|
|
45
|
+
readonly route: SyncularErrorRoute;
|
|
46
|
+
}) => void;
|
|
47
|
+
/**
|
|
48
|
+
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
49
|
+
* passes through unchanged. Anything else goes to `onError` and becomes
|
|
50
|
+
* `sync.internal_error` (§10.2), whose message never carries the exception
|
|
51
|
+
* text.
|
|
52
|
+
*/
|
|
53
|
+
export declare function adapterSyncError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): SyncError;
|
|
54
|
+
/** Hand `error` to the host's `onError`; a throwing handler is contained. */
|
|
55
|
+
export declare function reportError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): void;
|
package/dist/errors.js
CHANGED
|
@@ -163,6 +163,14 @@ export const ERROR_CATALOG = {
|
|
|
163
163
|
// checkpoint is incomplete or the database is newer than the running build.
|
|
164
164
|
// Formerly host-only; promoted to the §10.2 catalogue so an in-flight
|
|
165
165
|
// request can be refused with a distinguishable identity.
|
|
166
|
+
// An exception outside this catalog (host code, storage, network) reached
|
|
167
|
+
// an adapter; the original goes to `onError`, never onto the wire.
|
|
168
|
+
'sync.internal_error': {
|
|
169
|
+
category: 'internal',
|
|
170
|
+
retryable: true,
|
|
171
|
+
recommendedAction: 'retryLater',
|
|
172
|
+
httpStatus: 500,
|
|
173
|
+
},
|
|
166
174
|
'sync.schema_not_ready': {
|
|
167
175
|
category: 'internal',
|
|
168
176
|
retryable: true,
|
|
@@ -288,3 +296,24 @@ export function errorBody(error) {
|
|
|
288
296
|
: {}),
|
|
289
297
|
};
|
|
290
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
301
|
+
* passes through unchanged. Anything else goes to `onError` and becomes
|
|
302
|
+
* `sync.internal_error` (§10.2), whose message never carries the exception
|
|
303
|
+
* text.
|
|
304
|
+
*/
|
|
305
|
+
export function adapterSyncError(error, onError, route) {
|
|
306
|
+
if (error instanceof SyncError)
|
|
307
|
+
return error;
|
|
308
|
+
reportError(error, onError, route);
|
|
309
|
+
return new SyncError('sync.internal_error', 'internal server error');
|
|
310
|
+
}
|
|
311
|
+
/** Hand `error` to the host's `onError`; a throwing handler is contained. */
|
|
312
|
+
export function reportError(error, onError, route) {
|
|
313
|
+
try {
|
|
314
|
+
onError?.(error, { route });
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// The report is best effort; the response stays the catalog error.
|
|
318
|
+
}
|
|
319
|
+
}
|
package/dist/handler.js
CHANGED
|
@@ -353,7 +353,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
353
353
|
const limits = clampPullLimits(plan.pull);
|
|
354
354
|
const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
|
|
355
355
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
356
|
-
const prepared = await prepareSections(ctx, limits, plan.subscriptions, maxSeq, horizonSeq);
|
|
356
|
+
const prepared = await prepareSections(ctx, schema, limits, plan.subscriptions, maxSeq, horizonSeq, plan.logEpoch);
|
|
357
357
|
for (const [index, subscription] of plan.subscriptions.entries()) {
|
|
358
358
|
const trace = summaries !== undefined ? { segments: [] } : undefined;
|
|
359
359
|
const reads = prepared[index];
|
package/dist/index.d.ts
CHANGED
|
@@ -44,7 +44,7 @@ export * from './segment-store.js';
|
|
|
44
44
|
export * from './signed-url.js';
|
|
45
45
|
export * from './sqlite-dialect.js';
|
|
46
46
|
export * from './sqlite-driver.js';
|
|
47
|
-
export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, type SqliteImageBuilder, type SqliteImageInput, } from './sqlite-image.js';
|
|
47
|
+
export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, type SqliteImageBuilder, type SqliteImageInput, writeSqliteImage, } from './sqlite-image.js';
|
|
48
48
|
export * from './sqlite-blob-store.js';
|
|
49
49
|
export * from './sqlite-lease-store.js';
|
|
50
50
|
export * from './sqlite-segment-store.js';
|
package/dist/index.js
CHANGED
|
@@ -48,7 +48,7 @@ export * from './segment-store.js';
|
|
|
48
48
|
export * from './signed-url.js';
|
|
49
49
|
export * from './sqlite-dialect.js';
|
|
50
50
|
export * from './sqlite-driver.js';
|
|
51
|
-
export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, } from './sqlite-image.js';
|
|
51
|
+
export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, writeSqliteImage, } from './sqlite-image.js';
|
|
52
52
|
export * from './sqlite-blob-store.js';
|
|
53
53
|
export * from './sqlite-lease-store.js';
|
|
54
54
|
export * from './sqlite-segment-store.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decodeRemoteOperationRealtimeMessage, encodeRemoteOperationRealtimeMessage, } from '@syncular/core';
|
|
2
2
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context.js';
|
|
3
|
-
import { SyncError, syncError } from './errors.js';
|
|
3
|
+
import { reportError, SyncError, syncError } from './errors.js';
|
|
4
4
|
class WatchSession {
|
|
5
5
|
#ctx;
|
|
6
6
|
#registry;
|
|
@@ -122,6 +122,9 @@ class WatchSession {
|
|
|
122
122
|
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
|
+
if (!(error instanceof SyncError)) {
|
|
126
|
+
reportError(error, this.#ctx.onError, 'operations');
|
|
127
|
+
}
|
|
125
128
|
this.#sendError(state.watchId, error instanceof SyncError
|
|
126
129
|
? error
|
|
127
130
|
: syncError('operation.query_failed'));
|
package/dist/operations.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
|
|
2
2
|
import { decodeRow, decodeRemoteOperationRequest, encodeRemoteOperationResponse, encodeSparseRow, } from '@syncular/core';
|
|
3
3
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
4
|
-
import { SyncError, syncError } from './errors.js';
|
|
4
|
+
import { reportError, SyncError, syncError } from './errors.js';
|
|
5
5
|
import { processPushOperationsWithTrace } from './push.js';
|
|
6
6
|
import { compileSchema } from './schema.js';
|
|
7
7
|
import { authorizeWrite } from './scopes.js';
|
|
@@ -206,6 +206,7 @@ export function registerRemoteQuery(descriptor, options) {
|
|
|
206
206
|
catch (error) {
|
|
207
207
|
if (error instanceof SyncError)
|
|
208
208
|
throw error;
|
|
209
|
+
reportError(error, ctx.onError, 'operations');
|
|
209
210
|
throw syncError('operation.query_failed', 'registered query execution failed');
|
|
210
211
|
}
|
|
211
212
|
if (result.rows.length > options.maxRows) {
|
|
@@ -218,6 +219,7 @@ export function registerRemoteQuery(descriptor, options) {
|
|
|
218
219
|
catch (error) {
|
|
219
220
|
if (error instanceof SyncError)
|
|
220
221
|
throw error;
|
|
222
|
+
reportError(error, ctx.onError, 'operations');
|
|
221
223
|
throw syncError('operation.query_failed', 'registered query result decoding failed');
|
|
222
224
|
}
|
|
223
225
|
return {
|
|
@@ -409,6 +411,9 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
|
|
|
409
411
|
return encodeRemoteOperationResponse(await operation.run(ctx, request.clientId, request.requestId, request.params));
|
|
410
412
|
}
|
|
411
413
|
catch (error) {
|
|
414
|
+
if (!(error instanceof SyncError)) {
|
|
415
|
+
reportError(error, ctx.onError, 'operations');
|
|
416
|
+
}
|
|
412
417
|
return encodeRemoteOperationError(error, 'operation.execution_failed');
|
|
413
418
|
}
|
|
414
419
|
}
|
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
2
|
+
/**
|
|
3
|
+
* Postgres server storage: the production database path.
|
|
4
|
+
*
|
|
5
|
+
* Semantics mirror `SqliteServerStorage` exactly (both run the shared
|
|
6
|
+
* storage contract in `test/storage-contract.ts`); the difference is that
|
|
7
|
+
* scope fanout MUST survive contact with Postgres — no scan-before-LIMIT,
|
|
8
|
+
* ever (performance-by-construction):
|
|
9
|
+
*
|
|
10
|
+
* - both the commit log and the current-row table carry a (table, var,
|
|
11
|
+
* value) inverted scope index;
|
|
12
|
+
* - `readCommitWindow`/`scanRows` select candidates through that index
|
|
13
|
+
* (`sync_change_scopes` / `sync_row_scopes`), ordered + LIMITed at the
|
|
14
|
+
* index, then verify the full multi-variable match against the stored
|
|
15
|
+
* scope map — never a log or table scan;
|
|
16
|
+
* - the candidate indexes are *covering* for their selection: the ordered
|
|
17
|
+
* column (`commit_seq` / `row_id`) sits in the index key after
|
|
18
|
+
* (tbl, var, value), so the planner does an index range scan and returns
|
|
19
|
+
* already-ordered candidates without a heap sort. `test/postgres-explain
|
|
20
|
+
* .test.ts` asserts an `Index` node (never `Seq Scan`) so the regression
|
|
21
|
+
* cannot silently return.
|
|
22
|
+
*
|
|
23
|
+
* Storage is written against the `PgExecutor` seam (zero runtime deps); the
|
|
24
|
+
* production driver (Bun.sql / node-postgres) and the test driver (pglite)
|
|
25
|
+
* are wired by the host. See `pg-executor.ts` and the server README.
|
|
26
|
+
*
|
|
27
|
+
* ## commitSeq allocation under concurrency
|
|
28
|
+
*
|
|
29
|
+
* Per-partition `commitSeq` is a dense, gap-free counter (§2.1), allocated
|
|
30
|
+
* inside the push transaction by `UPDATE sync_partitions SET
|
|
31
|
+
* max_commit_seq = max_commit_seq + 1 … RETURNING`. The `UPDATE` takes a
|
|
32
|
+
* row-level write lock on the partition row for the duration of the
|
|
33
|
+
* transaction, so two concurrent pushes to the same partition serialize on
|
|
34
|
+
* that row: the second blocks until the first commits, then reads the
|
|
35
|
+
* advanced counter. This keeps the sequence dense (a Postgres `SEQUENCE`
|
|
36
|
+
* would leave gaps on rollback, which the pull-window arithmetic in §4.5
|
|
37
|
+
* does not tolerate). Cross-partition pushes never contend.
|
|
38
|
+
*/
|
|
39
|
+
import type { ScopeMap } from '@syncular/core';
|
|
2
40
|
import { type PgExecutor } from './pg-executor.js';
|
|
3
41
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
4
42
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
@@ -77,6 +115,10 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
77
115
|
getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
|
|
78
116
|
listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
|
|
79
117
|
pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
|
|
118
|
+
latestScopedChangeSeq(partition: string, query: {
|
|
119
|
+
readonly table: string;
|
|
120
|
+
readonly scopeFilter: ScopeMap;
|
|
121
|
+
}): Promise<number>;
|
|
80
122
|
readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
|
|
81
123
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
82
124
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -5,7 +5,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
5
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
6
6
|
import { syncError } from './errors.js';
|
|
7
7
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
8
|
-
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
8
|
+
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
9
9
|
import { matchesEffective } from './scopes.js';
|
|
10
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
11
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -403,12 +403,28 @@ async function scanRowsByIndexOn(q, compiled, partition, query) {
|
|
|
403
403
|
return rows.map(toStoredRow);
|
|
404
404
|
}
|
|
405
405
|
async function writeRowOn(q, compiled, partition, row) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
406
|
+
// One statement per row write: the upsert, and the scope index synced to
|
|
407
|
+
// the row's scope map (entries outside it deleted, missing ones inserted).
|
|
408
|
+
// No index entry is touched twice, so the writable CTEs cannot conflict.
|
|
409
|
+
// The scopes bind as text before the JSONB cast (see `appendCommit`).
|
|
410
|
+
const params = upsertValues(compiled, partition, row, 'postgres');
|
|
411
|
+
const n = params.length;
|
|
412
|
+
await q.query(`WITH upserted AS (${upsertSql(compiled, 'postgres')}),
|
|
413
|
+
scope AS (SELECT key, value FROM jsonb_each_text($${n + 1}::text::jsonb)),
|
|
414
|
+
removed AS (
|
|
415
|
+
DELETE FROM sync_row_scopes
|
|
416
|
+
WHERE partition=$${n + 2} AND tbl=$${n + 3} AND row_id=$${n + 4}
|
|
417
|
+
AND (var, value) NOT IN (SELECT key, value FROM scope)
|
|
418
|
+
)
|
|
419
|
+
INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
|
|
420
|
+
SELECT $${n + 2}, $${n + 3}, key, value, $${n + 4} FROM scope
|
|
421
|
+
ON CONFLICT DO NOTHING`, [
|
|
422
|
+
...params,
|
|
423
|
+
JSON.stringify(row.scopes),
|
|
424
|
+
partition,
|
|
425
|
+
compiled.name,
|
|
426
|
+
row.rowId,
|
|
427
|
+
]);
|
|
412
428
|
}
|
|
413
429
|
async function getPushResultOn(q, partition, clientId, clientCommitId) {
|
|
414
430
|
const { rows } = await q.query('SELECT result FROM sync_push_results WHERE partition=$1 AND client_id=$2 AND client_commit_id=$3', [partition, clientId, clientCommitId]);
|
|
@@ -442,6 +458,13 @@ class PostgresTransaction {
|
|
|
442
458
|
#resolveTable;
|
|
443
459
|
#open = true;
|
|
444
460
|
#pushApplySavepoint = false;
|
|
461
|
+
/**
|
|
462
|
+
* `prefetchRows` results keyed `table\u0000rowId`; `null` records a row
|
|
463
|
+
* (or tombstone) read as absent. A write in this transaction drops the
|
|
464
|
+
* key, so the next read goes to the database again.
|
|
465
|
+
*/
|
|
466
|
+
#prefetchedRows = new Map();
|
|
467
|
+
#prefetchedTombstones = new Map();
|
|
445
468
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
446
469
|
#resolve;
|
|
447
470
|
#reject;
|
|
@@ -473,16 +496,44 @@ class PostgresTransaction {
|
|
|
473
496
|
}
|
|
474
497
|
getRow(table, rowId) {
|
|
475
498
|
this.#assertOpen();
|
|
499
|
+
const prefetched = this.#prefetchedRows.get(`${table}\u0000${rowId}`);
|
|
500
|
+
if (prefetched !== undefined) {
|
|
501
|
+
return Promise.resolve(prefetched ?? undefined);
|
|
502
|
+
}
|
|
476
503
|
return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
|
|
477
504
|
}
|
|
505
|
+
async prefetchRows(table, rowIds) {
|
|
506
|
+
this.#assertOpen();
|
|
507
|
+
const ids = [...new Set(rowIds)].filter((rowId) => !this.#prefetchedRows.has(`${table}\u0000${rowId}`));
|
|
508
|
+
if (ids.length === 0)
|
|
509
|
+
return;
|
|
510
|
+
const { rows } = await this.#client.query(prefetchRowsSql(this.#resolveTable(table)), [
|
|
511
|
+
this.#partition,
|
|
512
|
+
JSON.stringify(ids),
|
|
513
|
+
table,
|
|
514
|
+
]);
|
|
515
|
+
for (const record of rows) {
|
|
516
|
+
const key = `${table}\u0000${record.lookup_id}`;
|
|
517
|
+
this.#prefetchedRows.set(key, record.row_id === null
|
|
518
|
+
? null
|
|
519
|
+
: toStoredRow({ ...record, row_id: record.row_id }));
|
|
520
|
+
this.#prefetchedTombstones.set(key, record.tombstone_seq === null || record.tombstone_seq === undefined
|
|
521
|
+
? null
|
|
522
|
+
: asNumber(record.tombstone_seq));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
478
525
|
async getTombstoneSeq(table, rowId) {
|
|
479
526
|
this.#assertOpen();
|
|
527
|
+
const prefetched = this.#prefetchedTombstones.get(`${table}\u0000${rowId}`);
|
|
528
|
+
if (prefetched !== undefined)
|
|
529
|
+
return prefetched ?? undefined;
|
|
480
530
|
const { rows } = await this.#client.query('SELECT commit_seq FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
|
|
481
531
|
const seq = rows[0]?.commit_seq;
|
|
482
532
|
return seq === null || seq === undefined ? undefined : asNumber(seq);
|
|
483
533
|
}
|
|
484
534
|
async clearTombstone(table, rowId) {
|
|
485
535
|
this.#assertOpen();
|
|
536
|
+
this.#prefetchedTombstones.delete(`${table}\u0000${rowId}`);
|
|
486
537
|
await this.#client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
|
|
487
538
|
}
|
|
488
539
|
getPushResult(clientId, clientCommitId) {
|
|
@@ -576,6 +627,7 @@ class PostgresTransaction {
|
|
|
576
627
|
}
|
|
577
628
|
async upsertRow(table, row, context) {
|
|
578
629
|
this.#assertOpen();
|
|
630
|
+
this.#prefetchedRows.delete(`${table}\u0000${row.rowId}`);
|
|
579
631
|
try {
|
|
580
632
|
await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
|
|
581
633
|
}
|
|
@@ -588,10 +640,14 @@ class PostgresTransaction {
|
|
|
588
640
|
}
|
|
589
641
|
async deleteRow(table, rowId) {
|
|
590
642
|
this.#assertOpen();
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
await this.#client.query(
|
|
643
|
+
this.#prefetchedRows.delete(`${table}\u0000${rowId}`);
|
|
644
|
+
// One statement: the row, its scope-index entries, and (§5.9.4: a
|
|
645
|
+
// deleted row references no blobs) its blob references.
|
|
646
|
+
await this.#client.query(`WITH deleted AS (${deleteRowSql(this.#resolveTable(table), 'postgres')}),
|
|
647
|
+
unscoped AS (
|
|
648
|
+
DELETE FROM sync_row_scopes WHERE partition=$1 AND tbl=$3 AND row_id=$2
|
|
649
|
+
)
|
|
650
|
+
DELETE FROM sync_blob_refs WHERE partition=$1 AND tbl=$3 AND row_id=$2`, [this.#partition, rowId, table]);
|
|
595
651
|
}
|
|
596
652
|
async setBlobRefs(table, rowId, blobIds) {
|
|
597
653
|
this.#assertOpen();
|
|
@@ -604,6 +660,8 @@ class PostgresTransaction {
|
|
|
604
660
|
}
|
|
605
661
|
async appendCommit(commit) {
|
|
606
662
|
this.#assertOpen();
|
|
663
|
+
// The commit writes its delete tombstones below.
|
|
664
|
+
this.#prefetchedTombstones.clear();
|
|
607
665
|
const q = this.#client;
|
|
608
666
|
const p = this.#partition;
|
|
609
667
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
@@ -626,40 +684,36 @@ class PostgresTransaction {
|
|
|
626
684
|
this.#writerVersion,
|
|
627
685
|
]);
|
|
628
686
|
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
JSON.stringify(change.scopes),
|
|
660
|
-
change.payload ?? null,
|
|
661
|
-
]);
|
|
662
|
-
}
|
|
687
|
+
if (commit.changes.length === 0)
|
|
688
|
+
return commitSeq;
|
|
689
|
+
// One statement for the whole commit: its changes, their inverted scope
|
|
690
|
+
// entries, and the §5 delete tombstones ride one writable-CTE chain.
|
|
691
|
+
// Serialized scopes bind as text before parsing JSONB; drivers that
|
|
692
|
+
// encode JSONB parameters would otherwise store the string as a scalar.
|
|
693
|
+
// A commit can delete one row twice, so the tombstone insert keeps one
|
|
694
|
+
// row per key (every candidate carries the same commit_seq).
|
|
695
|
+
const params = [p, commitSeq];
|
|
696
|
+
const values = commit.changes.map((change, idx) => {
|
|
697
|
+
const base = params.length;
|
|
698
|
+
params.push(idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
699
|
+
return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
700
|
+
});
|
|
701
|
+
await q.query(`WITH inserted AS (
|
|
702
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
703
|
+
VALUES ${values.join(',')}
|
|
704
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
705
|
+
),
|
|
706
|
+
scoped AS (
|
|
707
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
708
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
709
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
710
|
+
ON CONFLICT DO NOTHING
|
|
711
|
+
)
|
|
712
|
+
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
713
|
+
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
714
|
+
FROM inserted WHERE op = 2
|
|
715
|
+
ON CONFLICT (partition, tbl, row_id)
|
|
716
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`, params);
|
|
663
717
|
return commitSeq;
|
|
664
718
|
}
|
|
665
719
|
async putPushResult(clientId, clientCommitId, result) {
|
|
@@ -1348,6 +1402,17 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1348
1402
|
deadLetter: rows.filter((row) => row.status === 'dead-letter').length,
|
|
1349
1403
|
};
|
|
1350
1404
|
}
|
|
1405
|
+
async latestScopedChangeSeq(partition, query) {
|
|
1406
|
+
const firstVariable = Object.keys(query.scopeFilter).sort()[0];
|
|
1407
|
+
const values = firstVariable === undefined
|
|
1408
|
+
? []
|
|
1409
|
+
: (query.scopeFilter[firstVariable] ?? []);
|
|
1410
|
+
if (firstVariable === undefined || values.length === 0)
|
|
1411
|
+
return 0;
|
|
1412
|
+
// Concurrent reads of one table in a pull share one statement.
|
|
1413
|
+
const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, query.table, { var: firstVariable, vals: values });
|
|
1414
|
+
return row === undefined ? 0 : asNumber(row.seq);
|
|
1415
|
+
}
|
|
1351
1416
|
async readCommitWindow(partition, query) {
|
|
1352
1417
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
1353
1418
|
const firstVariable = variables[0];
|