@syncular/server 0.40.0 → 0.41.1
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/d1-storage.d.ts +2 -2
- package/dist/d1-storage.js +6 -3
- package/dist/handler.js +24 -3
- package/dist/postgres-storage.d.ts +2 -2
- package/dist/postgres-storage.js +4 -3
- package/dist/sqlite-storage.d.ts +2 -2
- package/dist/sqlite-storage.js +13 -2
- package/dist/storage.d.ts +13 -2
- package/package.json +2 -2
- package/src/d1-storage.ts +9 -2
- package/src/handler.ts +49 -10
- package/src/postgres-storage.ts +7 -2
- package/src/sqlite-storage.ts +16 -1
- package/src/storage.ts +18 -2
package/dist/d1-storage.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
42
42
|
*/
|
|
43
43
|
import { type ScopeMap } from '@syncular/core';
|
|
44
44
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
45
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
45
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
46
46
|
export interface D1PreparedStatement {
|
|
47
47
|
bind(...values: unknown[]): D1PreparedStatement;
|
|
48
48
|
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
@@ -123,7 +123,7 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
123
123
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
124
124
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
125
125
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
126
|
-
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
126
|
+
putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
|
|
127
127
|
recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
|
|
128
128
|
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
129
129
|
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -1605,10 +1605,11 @@ export class D1ServerStorage {
|
|
|
1605
1605
|
...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
|
|
1606
1606
|
};
|
|
1607
1607
|
}
|
|
1608
|
-
async putClientRecord(partition, record) {
|
|
1608
|
+
async putClientRecord(partition, record, options) {
|
|
1609
1609
|
// Update the existing record in place, so the table keeps one row per
|
|
1610
1610
|
// (partition, client_id). Every non-key field is written from the new
|
|
1611
1611
|
// record; a delete-and-insert would drop and recreate the row instead.
|
|
1612
|
+
// `keepRoundOutcome` leaves the round-state columns alone (§1.5, §4.5).
|
|
1612
1613
|
await this.#db
|
|
1613
1614
|
.prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
1614
1615
|
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
@@ -1617,9 +1618,11 @@ export class D1ServerStorage {
|
|
|
1617
1618
|
cursor=excluded.cursor,
|
|
1618
1619
|
subscriptions=excluded.subscriptions,
|
|
1619
1620
|
updated_at_ms=excluded.updated_at_ms,
|
|
1620
|
-
last_round_at_ms=excluded.last_round_at_ms
|
|
1621
|
+
last_round_at_ms=excluded.last_round_at_ms${options?.keepRoundOutcome === true
|
|
1622
|
+
? ''
|
|
1623
|
+
: `,
|
|
1621
1624
|
last_error_code=NULL,
|
|
1622
|
-
consecutive_failures=0`)
|
|
1625
|
+
consecutive_failures=0`}`)
|
|
1623
1626
|
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs)
|
|
1624
1627
|
.run();
|
|
1625
1628
|
}
|
package/dist/handler.js
CHANGED
|
@@ -106,10 +106,13 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
106
106
|
throw syncError('sync.invalid_request', 'missing REQ_HEADER');
|
|
107
107
|
}
|
|
108
108
|
const pushes = [];
|
|
109
|
+
let roundFailure;
|
|
109
110
|
let pull;
|
|
110
111
|
const subFrames = [];
|
|
111
112
|
for (const frame of request.frames) {
|
|
112
|
-
if (frame.type === '
|
|
113
|
+
if (frame.type === 'ROUND_FAILURE')
|
|
114
|
+
roundFailure = frame;
|
|
115
|
+
else if (frame.type === 'PUSH_COMMIT')
|
|
113
116
|
pushes.push(frame);
|
|
114
117
|
else if (frame.type === 'PULL_HEADER')
|
|
115
118
|
pull = frame;
|
|
@@ -130,6 +133,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
130
133
|
logEpoch: registry.logEpoch,
|
|
131
134
|
epochReset,
|
|
132
135
|
header,
|
|
136
|
+
roundFailure,
|
|
133
137
|
pushes,
|
|
134
138
|
pull,
|
|
135
139
|
subscriptions: [],
|
|
@@ -144,6 +148,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
144
148
|
logEpoch: registry.logEpoch,
|
|
145
149
|
epochReset: true,
|
|
146
150
|
header,
|
|
151
|
+
roundFailure,
|
|
147
152
|
pushes: [],
|
|
148
153
|
pull: undefined,
|
|
149
154
|
subscriptions: [],
|
|
@@ -212,6 +217,7 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
212
217
|
logEpoch: registry.logEpoch,
|
|
213
218
|
epochReset: false,
|
|
214
219
|
header,
|
|
220
|
+
roundFailure,
|
|
215
221
|
pushes,
|
|
216
222
|
pull,
|
|
217
223
|
subscriptions,
|
|
@@ -483,7 +489,11 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
483
489
|
cursor,
|
|
484
490
|
updatedAtMs: clockOf(ctx)(),
|
|
485
491
|
subscriptions,
|
|
486
|
-
}
|
|
492
|
+
},
|
|
493
|
+
// §4.5: a round that carried a `ROUND_FAILURE` report completes without
|
|
494
|
+
// clearing the reported code and streak; the next request without the
|
|
495
|
+
// frame clears them as a plain completed round does.
|
|
496
|
+
plan.roundFailure !== undefined ? { keepRoundOutcome: true } : undefined);
|
|
487
497
|
if (ctx.realtime?.notifySubscriptions !== undefined &&
|
|
488
498
|
JSON.stringify((previous?.subscriptions ?? []).map((s) => [s.id, s.table, s.scopes])) !== JSON.stringify(subscriptions.map((s) => [s.id, s.table, s.scopes]))) {
|
|
489
499
|
await ctx.realtime.notifySubscriptions(ctx.partition, plan.header.clientId);
|
|
@@ -583,10 +593,15 @@ function requestClientId(bytes) {
|
|
|
583
593
|
* `onError` and never replaces the round's own outcome.
|
|
584
594
|
*/
|
|
585
595
|
async function recordRoundFailure(ctx, clientId, error) {
|
|
596
|
+
await recordRoundFailureCode(ctx, clientId, error instanceof SyncError ? error.code : 'sync.internal_error');
|
|
597
|
+
}
|
|
598
|
+
/** As `recordRoundFailure`, for a code that is not a thrown catalog error
|
|
599
|
+
* (a `ROUND_FAILURE` report, §1.5). */
|
|
600
|
+
async function recordRoundFailureCode(ctx, clientId, code) {
|
|
586
601
|
if (clientId === undefined)
|
|
587
602
|
return;
|
|
588
603
|
try {
|
|
589
|
-
await ctx.storage.recordClientRoundFailure(ctx.partition, clientId, ctx.actorId,
|
|
604
|
+
await ctx.storage.recordClientRoundFailure(ctx.partition, clientId, ctx.actorId, code, clockOf(ctx)());
|
|
590
605
|
}
|
|
591
606
|
catch (failure) {
|
|
592
607
|
reportError(failure, ctx.onError, 'sync');
|
|
@@ -648,6 +663,12 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
648
663
|
candidate.version === header.schemaVersion);
|
|
649
664
|
const served = selected ?? schema;
|
|
650
665
|
const plan = await planRequest(request, ctx, served, registry);
|
|
666
|
+
// §1.5/§4.5: record the reported round failure on the existing record
|
|
667
|
+
// before the round runs (it never creates one, never touches another
|
|
668
|
+
// actor's record). The completion of this round leaves it in place.
|
|
669
|
+
if (plan.roundFailure !== undefined) {
|
|
670
|
+
await recordRoundFailureCode(ctx, plan.header.clientId, plan.roundFailure.code);
|
|
671
|
+
}
|
|
651
672
|
const report = { outcome: 'ok' };
|
|
652
673
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
653
674
|
// builds the whole response (its data reads) before yielding the first
|
|
@@ -39,7 +39,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
39
39
|
import type { ScopeMap } from '@syncular/core';
|
|
40
40
|
import { type PgExecutor } from './pg-executor.js';
|
|
41
41
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
42
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
42
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
43
43
|
/**
|
|
44
44
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
45
45
|
*
|
|
@@ -129,7 +129,7 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
129
129
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
130
130
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
131
131
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
132
|
-
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
132
|
+
putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
|
|
133
133
|
recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
|
|
134
134
|
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
135
135
|
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1827,7 +1827,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1827
1827
|
: asNumber(record.last_round_at_ms), record.last_error_code, asNumber(record.consecutive_failures)),
|
|
1828
1828
|
};
|
|
1829
1829
|
}
|
|
1830
|
-
async putClientRecord(partition, record) {
|
|
1830
|
+
async putClientRecord(partition, record, options) {
|
|
1831
1831
|
await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
|
|
1832
1832
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
|
|
1833
1833
|
ON CONFLICT (partition, client_id) DO UPDATE
|
|
@@ -1835,8 +1835,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1835
1835
|
cursor=EXCLUDED.cursor,
|
|
1836
1836
|
subscriptions=EXCLUDED.subscriptions,
|
|
1837
1837
|
updated_at_ms=EXCLUDED.updated_at_ms,
|
|
1838
|
-
last_round_at_ms=EXCLUDED.last_round_at_ms
|
|
1839
|
-
|
|
1838
|
+
last_round_at_ms=EXCLUDED.last_round_at_ms${options?.keepRoundOutcome === true
|
|
1839
|
+
? ''
|
|
1840
|
+
: ',\n last_error_code=NULL, consecutive_failures=0'}`, [
|
|
1840
1841
|
partition,
|
|
1841
1842
|
record.clientId,
|
|
1842
1843
|
record.actorId,
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
2
2
|
import type { ScopeMap } from '@syncular/core';
|
|
3
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
4
4
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
5
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
5
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, ClientRecordWriteOptions, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
6
6
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
7
7
|
#private;
|
|
8
8
|
readonly db: SqliteDatabase;
|
|
@@ -56,7 +56,7 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
56
56
|
scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
|
|
57
57
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
58
58
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
59
|
-
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
59
|
+
putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
|
|
60
60
|
recordClientRoundFailure(partition: string, clientId: string, actorId: string, errorCode: string, atMs: number): Promise<void>;
|
|
61
61
|
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
62
62
|
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1175,9 +1175,20 @@ END`);
|
|
|
1175
1175
|
...clientRoundState(record.last_round_at_ms, record.last_error_code, record.consecutive_failures),
|
|
1176
1176
|
};
|
|
1177
1177
|
}
|
|
1178
|
-
async putClientRecord(partition, record) {
|
|
1178
|
+
async putClientRecord(partition, record, options) {
|
|
1179
1179
|
this.db
|
|
1180
|
-
.query(
|
|
1180
|
+
.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
1181
|
+
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
1182
|
+
actor_id=excluded.actor_id,
|
|
1183
|
+
wire_version=excluded.wire_version,
|
|
1184
|
+
cursor=excluded.cursor,
|
|
1185
|
+
subscriptions=excluded.subscriptions,
|
|
1186
|
+
updated_at_ms=excluded.updated_at_ms,
|
|
1187
|
+
last_round_at_ms=excluded.last_round_at_ms${options?.keepRoundOutcome === true
|
|
1188
|
+
? ''
|
|
1189
|
+
: `,
|
|
1190
|
+
last_error_code=NULL,
|
|
1191
|
+
consecutive_failures=0`}`)
|
|
1181
1192
|
.run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs, record.updatedAtMs);
|
|
1182
1193
|
}
|
|
1183
1194
|
async recordClientRoundFailure(partition, clientId, actorId, errorCode, atMs) {
|
package/dist/storage.d.ts
CHANGED
|
@@ -242,6 +242,15 @@ export interface ClientRecord {
|
|
|
242
242
|
/** Rounds that failed in a row since the last successful round. */
|
|
243
243
|
readonly consecutiveFailures?: number;
|
|
244
244
|
}
|
|
245
|
+
/** `putClientRecord` options. */
|
|
246
|
+
export interface ClientRecordWriteOptions {
|
|
247
|
+
/**
|
|
248
|
+
* Leave the stored round outcome (`lastErrorCode`, `consecutiveFailures`)
|
|
249
|
+
* untouched. Set for a completed round that carried a `ROUND_FAILURE` report
|
|
250
|
+
* (§1.5, §4.5). `lastRoundAtMs` still moves to this round's completion.
|
|
251
|
+
*/
|
|
252
|
+
readonly keepRoundOutcome?: boolean;
|
|
253
|
+
}
|
|
245
254
|
/** The round-state fields of a stored `sync_clients` row. */
|
|
246
255
|
export declare function clientRoundState(lastRoundAtMs: number | null, lastErrorCode: string | null, consecutiveFailures: number): Pick<ClientRecord, 'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'>;
|
|
247
256
|
export interface CommitWindowQuery {
|
|
@@ -690,9 +699,11 @@ export interface ServerStorage {
|
|
|
690
699
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
691
700
|
/**
|
|
692
701
|
* Write the record for a completed round. The round-state fields reset: the
|
|
693
|
-
* stored last round is a success at `updatedAtMs` with no failures
|
|
702
|
+
* stored last round is a success at `updatedAtMs` with no failures — unless
|
|
703
|
+
* `keepRoundOutcome` is set, which leaves the reported code and streak in
|
|
704
|
+
* place (the round carried a `ROUND_FAILURE` report, §1.5/§4.5).
|
|
694
705
|
*/
|
|
695
|
-
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
706
|
+
putClientRecord(partition: string, record: ClientRecord, options?: ClientRecordWriteOptions): Promise<void>;
|
|
696
707
|
/**
|
|
697
708
|
* Record a failed round on an existing record: set `lastRoundAtMs` and
|
|
698
709
|
* `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
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.41.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -116,6 +116,7 @@ import type {
|
|
|
116
116
|
ClientCursorInfo,
|
|
117
117
|
ClientPushResult,
|
|
118
118
|
ClientRecord,
|
|
119
|
+
ClientRecordWriteOptions,
|
|
119
120
|
ClientSubscription,
|
|
120
121
|
CommitChangeSummary,
|
|
121
122
|
CommitDetail,
|
|
@@ -2416,10 +2417,12 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2416
2417
|
async putClientRecord(
|
|
2417
2418
|
partition: string,
|
|
2418
2419
|
record: ClientRecord,
|
|
2420
|
+
options?: ClientRecordWriteOptions,
|
|
2419
2421
|
): Promise<void> {
|
|
2420
2422
|
// Update the existing record in place, so the table keeps one row per
|
|
2421
2423
|
// (partition, client_id). Every non-key field is written from the new
|
|
2422
2424
|
// record; a delete-and-insert would drop and recreate the row instead.
|
|
2425
|
+
// `keepRoundOutcome` leaves the round-state columns alone (§1.5, §4.5).
|
|
2423
2426
|
await this.#db
|
|
2424
2427
|
.prepare(
|
|
2425
2428
|
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
@@ -2429,9 +2432,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2429
2432
|
cursor=excluded.cursor,
|
|
2430
2433
|
subscriptions=excluded.subscriptions,
|
|
2431
2434
|
updated_at_ms=excluded.updated_at_ms,
|
|
2432
|
-
last_round_at_ms=excluded.last_round_at_ms
|
|
2435
|
+
last_round_at_ms=excluded.last_round_at_ms${
|
|
2436
|
+
options?.keepRoundOutcome === true
|
|
2437
|
+
? ''
|
|
2438
|
+
: `,
|
|
2433
2439
|
last_error_code=NULL,
|
|
2434
|
-
consecutive_failures=0
|
|
2440
|
+
consecutive_failures=0`
|
|
2441
|
+
}`,
|
|
2435
2442
|
)
|
|
2436
2443
|
.bind(
|
|
2437
2444
|
partition,
|
package/src/handler.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ReqHeaderFrame,
|
|
20
20
|
type RequestMessage,
|
|
21
21
|
type ResponseFrame,
|
|
22
|
+
type RoundFailureFrame,
|
|
22
23
|
type ScopeMap,
|
|
23
24
|
type SubscriptionFrame,
|
|
24
25
|
} from '@syncular/core';
|
|
@@ -70,6 +71,8 @@ interface RequestPlan {
|
|
|
70
71
|
readonly logEpoch: string;
|
|
71
72
|
readonly epochReset: boolean;
|
|
72
73
|
readonly header: ReqHeaderFrame;
|
|
74
|
+
/** §1.5: the code this request reports for its last unseen round failure. */
|
|
75
|
+
readonly roundFailure: RoundFailureFrame | undefined;
|
|
73
76
|
readonly pushes: readonly PushCommitFrame[];
|
|
74
77
|
readonly pull: PullHeaderFrame | undefined;
|
|
75
78
|
readonly subscriptions: readonly SubscriptionPlan[];
|
|
@@ -212,10 +215,12 @@ async function planRequest(
|
|
|
212
215
|
throw syncError('sync.invalid_request', 'missing REQ_HEADER');
|
|
213
216
|
}
|
|
214
217
|
const pushes: PushCommitFrame[] = [];
|
|
218
|
+
let roundFailure: RoundFailureFrame | undefined;
|
|
215
219
|
let pull: PullHeaderFrame | undefined;
|
|
216
220
|
const subFrames: SubscriptionFrame[] = [];
|
|
217
221
|
for (const frame of request.frames) {
|
|
218
|
-
if (frame.type === '
|
|
222
|
+
if (frame.type === 'ROUND_FAILURE') roundFailure = frame;
|
|
223
|
+
else if (frame.type === 'PUSH_COMMIT') pushes.push(frame);
|
|
219
224
|
else if (frame.type === 'PULL_HEADER') pull = frame;
|
|
220
225
|
else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
|
|
221
226
|
}
|
|
@@ -241,6 +246,7 @@ async function planRequest(
|
|
|
241
246
|
logEpoch: registry.logEpoch,
|
|
242
247
|
epochReset,
|
|
243
248
|
header,
|
|
249
|
+
roundFailure,
|
|
244
250
|
pushes,
|
|
245
251
|
pull,
|
|
246
252
|
subscriptions: [],
|
|
@@ -256,6 +262,7 @@ async function planRequest(
|
|
|
256
262
|
logEpoch: registry.logEpoch,
|
|
257
263
|
epochReset: true,
|
|
258
264
|
header,
|
|
265
|
+
roundFailure,
|
|
259
266
|
pushes: [],
|
|
260
267
|
pull: undefined,
|
|
261
268
|
subscriptions: [],
|
|
@@ -362,6 +369,7 @@ async function planRequest(
|
|
|
362
369
|
logEpoch: registry.logEpoch,
|
|
363
370
|
epochReset: false,
|
|
364
371
|
header,
|
|
372
|
+
roundFailure,
|
|
365
373
|
pushes,
|
|
366
374
|
pull,
|
|
367
375
|
subscriptions,
|
|
@@ -709,14 +717,21 @@ async function* streamResponse(
|
|
|
709
717
|
: [...(previous?.subscriptions ?? [])];
|
|
710
718
|
const cursor =
|
|
711
719
|
cursors.length > 0 ? Math.min(...cursors) : (previous?.cursor ?? -1);
|
|
712
|
-
await ctx.storage.putClientRecord(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
+
await ctx.storage.putClientRecord(
|
|
721
|
+
ctx.partition,
|
|
722
|
+
{
|
|
723
|
+
clientId: plan.header.clientId,
|
|
724
|
+
actorId: ctx.actorId,
|
|
725
|
+
wireVersion: plan.wireVersion,
|
|
726
|
+
cursor,
|
|
727
|
+
updatedAtMs: clockOf(ctx)(),
|
|
728
|
+
subscriptions,
|
|
729
|
+
},
|
|
730
|
+
// §4.5: a round that carried a `ROUND_FAILURE` report completes without
|
|
731
|
+
// clearing the reported code and streak; the next request without the
|
|
732
|
+
// frame clears them as a plain completed round does.
|
|
733
|
+
plan.roundFailure !== undefined ? { keepRoundOutcome: true } : undefined,
|
|
734
|
+
);
|
|
720
735
|
if (
|
|
721
736
|
ctx.realtime?.notifySubscriptions !== undefined &&
|
|
722
737
|
JSON.stringify(
|
|
@@ -831,6 +846,20 @@ async function recordRoundFailure(
|
|
|
831
846
|
ctx: SyncRequestContext,
|
|
832
847
|
clientId: string | undefined,
|
|
833
848
|
error: unknown,
|
|
849
|
+
): Promise<void> {
|
|
850
|
+
await recordRoundFailureCode(
|
|
851
|
+
ctx,
|
|
852
|
+
clientId,
|
|
853
|
+
error instanceof SyncError ? error.code : 'sync.internal_error',
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** As `recordRoundFailure`, for a code that is not a thrown catalog error
|
|
858
|
+
* (a `ROUND_FAILURE` report, §1.5). */
|
|
859
|
+
async function recordRoundFailureCode(
|
|
860
|
+
ctx: SyncRequestContext,
|
|
861
|
+
clientId: string | undefined,
|
|
862
|
+
code: string,
|
|
834
863
|
): Promise<void> {
|
|
835
864
|
if (clientId === undefined) return;
|
|
836
865
|
try {
|
|
@@ -838,7 +867,7 @@ async function recordRoundFailure(
|
|
|
838
867
|
ctx.partition,
|
|
839
868
|
clientId,
|
|
840
869
|
ctx.actorId,
|
|
841
|
-
|
|
870
|
+
code,
|
|
842
871
|
clockOf(ctx)(),
|
|
843
872
|
);
|
|
844
873
|
} catch (failure) {
|
|
@@ -917,6 +946,16 @@ async function createStreamCore(
|
|
|
917
946
|
);
|
|
918
947
|
const served = selected ?? schema;
|
|
919
948
|
const plan = await planRequest(request, ctx, served, registry);
|
|
949
|
+
// §1.5/§4.5: record the reported round failure on the existing record
|
|
950
|
+
// before the round runs (it never creates one, never touches another
|
|
951
|
+
// actor's record). The completion of this round leaves it in place.
|
|
952
|
+
if (plan.roundFailure !== undefined) {
|
|
953
|
+
await recordRoundFailureCode(
|
|
954
|
+
ctx,
|
|
955
|
+
plan.header.clientId,
|
|
956
|
+
plan.roundFailure.code,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
920
959
|
const report: RequestReport = { outcome: 'ok' };
|
|
921
960
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
922
961
|
// builds the whole response (its data reads) before yielding the first
|
package/src/postgres-storage.ts
CHANGED
|
@@ -105,6 +105,7 @@ import type {
|
|
|
105
105
|
ClientCursorInfo,
|
|
106
106
|
ClientPushResult,
|
|
107
107
|
ClientRecord,
|
|
108
|
+
ClientRecordWriteOptions,
|
|
108
109
|
ClientSubscription,
|
|
109
110
|
CommitChangeSummary,
|
|
110
111
|
CommitDetail,
|
|
@@ -2778,6 +2779,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2778
2779
|
async putClientRecord(
|
|
2779
2780
|
partition: string,
|
|
2780
2781
|
record: ClientRecord,
|
|
2782
|
+
options?: ClientRecordWriteOptions,
|
|
2781
2783
|
): Promise<void> {
|
|
2782
2784
|
await this.#exec.query(
|
|
2783
2785
|
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
|
|
@@ -2787,8 +2789,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2787
2789
|
cursor=EXCLUDED.cursor,
|
|
2788
2790
|
subscriptions=EXCLUDED.subscriptions,
|
|
2789
2791
|
updated_at_ms=EXCLUDED.updated_at_ms,
|
|
2790
|
-
last_round_at_ms=EXCLUDED.last_round_at_ms
|
|
2791
|
-
|
|
2792
|
+
last_round_at_ms=EXCLUDED.last_round_at_ms${
|
|
2793
|
+
options?.keepRoundOutcome === true
|
|
2794
|
+
? ''
|
|
2795
|
+
: ',\n last_error_code=NULL, consecutive_failures=0'
|
|
2796
|
+
}`,
|
|
2792
2797
|
[
|
|
2793
2798
|
partition,
|
|
2794
2799
|
record.clientId,
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -89,6 +89,7 @@ import type {
|
|
|
89
89
|
ClientCursorInfo,
|
|
90
90
|
ClientPushResult,
|
|
91
91
|
ClientRecord,
|
|
92
|
+
ClientRecordWriteOptions,
|
|
92
93
|
ClientSubscription,
|
|
93
94
|
CommitChangeSummary,
|
|
94
95
|
CommitDetail,
|
|
@@ -1892,10 +1893,24 @@ END`);
|
|
|
1892
1893
|
async putClientRecord(
|
|
1893
1894
|
partition: string,
|
|
1894
1895
|
record: ClientRecord,
|
|
1896
|
+
options?: ClientRecordWriteOptions,
|
|
1895
1897
|
): Promise<void> {
|
|
1896
1898
|
this.db
|
|
1897
1899
|
.query(
|
|
1898
|
-
|
|
1900
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
1901
|
+
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
1902
|
+
actor_id=excluded.actor_id,
|
|
1903
|
+
wire_version=excluded.wire_version,
|
|
1904
|
+
cursor=excluded.cursor,
|
|
1905
|
+
subscriptions=excluded.subscriptions,
|
|
1906
|
+
updated_at_ms=excluded.updated_at_ms,
|
|
1907
|
+
last_round_at_ms=excluded.last_round_at_ms${
|
|
1908
|
+
options?.keepRoundOutcome === true
|
|
1909
|
+
? ''
|
|
1910
|
+
: `,
|
|
1911
|
+
last_error_code=NULL,
|
|
1912
|
+
consecutive_failures=0`
|
|
1913
|
+
}`,
|
|
1899
1914
|
)
|
|
1900
1915
|
.run(
|
|
1901
1916
|
partition,
|
package/src/storage.ts
CHANGED
|
@@ -313,6 +313,16 @@ export interface ClientRecord {
|
|
|
313
313
|
readonly consecutiveFailures?: number;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/** `putClientRecord` options. */
|
|
317
|
+
export interface ClientRecordWriteOptions {
|
|
318
|
+
/**
|
|
319
|
+
* Leave the stored round outcome (`lastErrorCode`, `consecutiveFailures`)
|
|
320
|
+
* untouched. Set for a completed round that carried a `ROUND_FAILURE` report
|
|
321
|
+
* (§1.5, §4.5). `lastRoundAtMs` still moves to this round's completion.
|
|
322
|
+
*/
|
|
323
|
+
readonly keepRoundOutcome?: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
316
326
|
/** The round-state fields of a stored `sync_clients` row. */
|
|
317
327
|
export function clientRoundState(
|
|
318
328
|
lastRoundAtMs: number | null,
|
|
@@ -941,9 +951,15 @@ export interface ServerStorage {
|
|
|
941
951
|
): Promise<ClientRecord | undefined>;
|
|
942
952
|
/**
|
|
943
953
|
* Write the record for a completed round. The round-state fields reset: the
|
|
944
|
-
* stored last round is a success at `updatedAtMs` with no failures
|
|
954
|
+
* stored last round is a success at `updatedAtMs` with no failures — unless
|
|
955
|
+
* `keepRoundOutcome` is set, which leaves the reported code and streak in
|
|
956
|
+
* place (the round carried a `ROUND_FAILURE` report, §1.5/§4.5).
|
|
945
957
|
*/
|
|
946
|
-
putClientRecord(
|
|
958
|
+
putClientRecord(
|
|
959
|
+
partition: string,
|
|
960
|
+
record: ClientRecord,
|
|
961
|
+
options?: ClientRecordWriteOptions,
|
|
962
|
+
): Promise<void>;
|
|
947
963
|
/**
|
|
948
964
|
* Record a failed round on an existing record: set `lastRoundAtMs` and
|
|
949
965
|
* `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
|