@syncular/server 0.15.44 → 0.15.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -5
- package/dist/admin.d.ts +12 -6
- package/dist/admin.js +11 -1
- package/dist/authoritative-query.d.ts +20 -0
- package/dist/authoritative-query.js +184 -0
- package/dist/blob-store.d.ts +2 -2
- package/dist/context.d.ts +11 -2
- package/dist/context.js +2 -0
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +219 -3
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +43 -1
- package/dist/events-ring.d.ts +1 -1
- package/dist/events-ring.js +1 -1
- package/dist/events.d.ts +52 -3
- package/dist/handler.js +6 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/operations-realtime.d.ts +16 -0
- package/dist/operations-realtime.js +196 -0
- package/dist/operations.d.ts +97 -0
- package/dist/operations.js +392 -0
- package/dist/pg-executor.d.ts +1 -1
- package/dist/pg-executor.js +1 -1
- package/dist/postgres-fanout.d.ts +1 -1
- package/dist/postgres-storage.d.ts +11 -2
- package/dist/postgres-storage.js +223 -3
- package/dist/pull.js +1 -1
- package/dist/push.d.ts +8 -2
- package/dist/push.js +75 -21
- package/dist/reactions.d.ts +167 -0
- package/dist/reactions.js +442 -0
- package/dist/realtime.js +4 -1
- package/dist/relational-rows.d.ts +9 -9
- package/dist/relational-rows.js +9 -9
- package/dist/s3-blob-store.js +1 -1
- package/dist/s3-segment-store.js +1 -1
- package/dist/schema.d.ts +4 -5
- package/dist/schema.js +3 -3
- package/dist/seed.js +1 -1
- package/dist/segment-store.d.ts +3 -3
- package/dist/signed-url.js +2 -2
- package/dist/sigv4.d.ts +2 -4
- package/dist/sigv4.js +3 -3
- package/dist/sqlite-blob-store.d.ts +1 -1
- package/dist/sqlite-blob-store.js +1 -1
- package/dist/sqlite-dialect.d.ts +3 -4
- package/dist/sqlite-dialect.js +21 -1
- package/dist/sqlite-image.d.ts +1 -1
- package/dist/sqlite-lease-store.d.ts +1 -1
- package/dist/sqlite-lease-store.js +1 -1
- package/dist/sqlite-segment-store.d.ts +2 -2
- package/dist/sqlite-segment-store.js +2 -2
- package/dist/sqlite-storage.d.ts +11 -2
- package/dist/sqlite-storage.js +219 -4
- package/dist/storage.d.ts +112 -3
- package/dist/validate.js +1 -0
- package/package.json +2 -2
- package/src/admin.ts +30 -6
- package/src/authoritative-query.ts +218 -0
- package/src/blob-store.ts +0 -0
- package/src/context.ts +12 -2
- package/src/d1-storage.ts +355 -3
- package/src/errors.ts +43 -1
- package/src/events-ring.ts +1 -1
- package/src/events.ts +64 -2
- package/src/handler.ts +15 -3
- package/src/index.ts +33 -1
- package/src/operations-realtime.ts +272 -0
- package/src/operations.ts +720 -0
- package/src/pg-executor.ts +1 -1
- package/src/postgres-fanout.ts +1 -1
- package/src/postgres-storage.ts +355 -4
- package/src/pull.ts +1 -1
- package/src/push.ts +97 -29
- package/src/reactions.ts +741 -0
- package/src/realtime.ts +7 -1
- package/src/relational-rows.ts +9 -9
- package/src/s3-blob-store.ts +1 -1
- package/src/s3-segment-store.ts +1 -1
- package/src/schema.ts +6 -7
- package/src/seed.ts +1 -1
- package/src/segment-store.ts +3 -3
- package/src/signed-url.ts +2 -2
- package/src/sigv4.ts +3 -5
- package/src/sqlite-blob-store.ts +1 -1
- package/src/sqlite-dialect.ts +22 -3
- package/src/sqlite-image.ts +1 -1
- package/src/sqlite-lease-store.ts +1 -1
- package/src/sqlite-segment-store.ts +2 -2
- package/src/sqlite-storage.ts +369 -4
- package/src/storage.ts +168 -3
- package/src/validate.ts +1 -0
package/src/errors.ts
CHANGED
|
@@ -14,8 +14,50 @@ export interface ErrorCatalogEntry {
|
|
|
14
14
|
readonly httpStatus: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** The §10.2 wire catalog
|
|
17
|
+
/** The §10.2 wire catalog, keyed by stable code. */
|
|
18
18
|
export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
19
|
+
'operation.unknown': {
|
|
20
|
+
category: 'not-found',
|
|
21
|
+
retryable: false,
|
|
22
|
+
recommendedAction: 'regenerateClient',
|
|
23
|
+
httpStatus: 404,
|
|
24
|
+
},
|
|
25
|
+
'operation.forbidden': {
|
|
26
|
+
category: 'forbidden',
|
|
27
|
+
retryable: false,
|
|
28
|
+
recommendedAction: 'checkPermissions',
|
|
29
|
+
httpStatus: 403,
|
|
30
|
+
},
|
|
31
|
+
'operation.invalid_request': {
|
|
32
|
+
category: 'invalid-request',
|
|
33
|
+
retryable: false,
|
|
34
|
+
recommendedAction: 'fixRequest',
|
|
35
|
+
httpStatus: 400,
|
|
36
|
+
},
|
|
37
|
+
'operation.result_too_large': {
|
|
38
|
+
category: 'invalid-request',
|
|
39
|
+
retryable: false,
|
|
40
|
+
recommendedAction: 'fixRequest',
|
|
41
|
+
httpStatus: 400,
|
|
42
|
+
},
|
|
43
|
+
'operation.storage_unsupported': {
|
|
44
|
+
category: 'internal',
|
|
45
|
+
retryable: false,
|
|
46
|
+
recommendedAction: 'inspectServer',
|
|
47
|
+
httpStatus: 500,
|
|
48
|
+
},
|
|
49
|
+
'operation.query_failed': {
|
|
50
|
+
category: 'internal',
|
|
51
|
+
retryable: false,
|
|
52
|
+
recommendedAction: 'inspectServer',
|
|
53
|
+
httpStatus: 500,
|
|
54
|
+
},
|
|
55
|
+
'operation.execution_failed': {
|
|
56
|
+
category: 'internal',
|
|
57
|
+
retryable: false,
|
|
58
|
+
recommendedAction: 'inspectServer',
|
|
59
|
+
httpStatus: 500,
|
|
60
|
+
},
|
|
19
61
|
'sync.auth_required': {
|
|
20
62
|
category: 'auth-required',
|
|
21
63
|
retryable: true,
|
package/src/events-ring.ts
CHANGED
package/src/events.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structured server events — the one operational seam (host surface,
|
|
3
|
-
* wire protocol
|
|
2
|
+
* Structured server events — the one operational seam (host surface, outside
|
|
3
|
+
* the wire protocol).
|
|
4
4
|
*
|
|
5
5
|
* Design rules:
|
|
6
6
|
* - Every event is a flat, JSON-able object with a stable `type` string:
|
|
@@ -81,6 +81,62 @@ export interface PushConflictedEvent extends PushEventBase {
|
|
|
81
81
|
readonly cacheIdentity?: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** A planned reaction committed atomically with its accepted source commit. */
|
|
85
|
+
export interface ReactionQueuedEvent {
|
|
86
|
+
readonly type: 'reaction.queued';
|
|
87
|
+
readonly atMs: number;
|
|
88
|
+
readonly partition: string;
|
|
89
|
+
readonly actorId: string;
|
|
90
|
+
readonly clientId: string;
|
|
91
|
+
readonly clientCommitId: string;
|
|
92
|
+
readonly commitSeq: number;
|
|
93
|
+
readonly idempotencyKey: string;
|
|
94
|
+
readonly reactionType: string;
|
|
95
|
+
readonly version: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ReactionDeliveryEventBase {
|
|
99
|
+
readonly atMs: number;
|
|
100
|
+
readonly partition: string;
|
|
101
|
+
readonly workerId: string;
|
|
102
|
+
readonly idempotencyKey: string;
|
|
103
|
+
readonly reactionType: string;
|
|
104
|
+
readonly version: number;
|
|
105
|
+
readonly attempt: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ReactionStartedEvent extends ReactionDeliveryEventBase {
|
|
109
|
+
readonly type: 'reaction.started';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ReactionRetriedEvent extends ReactionDeliveryEventBase {
|
|
113
|
+
readonly type: 'reaction.retried';
|
|
114
|
+
readonly nextAttemptAtMs: number;
|
|
115
|
+
readonly errorCode: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ReactionCompletedEvent extends ReactionDeliveryEventBase {
|
|
119
|
+
readonly type: 'reaction.completed';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ReactionDeadLetteredEvent extends ReactionDeliveryEventBase {
|
|
123
|
+
readonly type: 'reaction.dead_lettered';
|
|
124
|
+
readonly errorCode: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One bounded terminal-reaction retention pass. */
|
|
128
|
+
export interface ReactionPruneCompletedEvent {
|
|
129
|
+
readonly type: 'reaction.prune_completed';
|
|
130
|
+
readonly atMs: number;
|
|
131
|
+
readonly partition: string;
|
|
132
|
+
readonly completedBeforeMs: number;
|
|
133
|
+
readonly deadLetterBeforeMs: number;
|
|
134
|
+
readonly limit: number;
|
|
135
|
+
readonly removedCompleted: number;
|
|
136
|
+
readonly removedDeadLetter: number;
|
|
137
|
+
readonly mayHaveMore: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
84
140
|
/** One emitted segment within a pull subscription section. */
|
|
85
141
|
export interface PullSegmentSummary {
|
|
86
142
|
readonly mediaType: 'rows' | 'sqlite';
|
|
@@ -248,6 +304,12 @@ export type SyncularServerEvent =
|
|
|
248
304
|
| PushAppliedEvent
|
|
249
305
|
| PushRejectedEvent
|
|
250
306
|
| PushConflictedEvent
|
|
307
|
+
| ReactionQueuedEvent
|
|
308
|
+
| ReactionStartedEvent
|
|
309
|
+
| ReactionRetriedEvent
|
|
310
|
+
| ReactionCompletedEvent
|
|
311
|
+
| ReactionDeadLetteredEvent
|
|
312
|
+
| ReactionPruneCompletedEvent
|
|
251
313
|
| PullServedEvent
|
|
252
314
|
| SegmentDownloadedEvent
|
|
253
315
|
| BlobUploadedEvent
|
package/src/handler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `handleSyncRequest(bytes, ctx) → bytes` (SPEC.md §1
|
|
2
|
+
* `handleSyncRequest(bytes, ctx) → bytes` (SPEC.md §1).
|
|
3
3
|
*
|
|
4
4
|
* Internally streaming-friendly (§1.4): `createSyncResponseStream` returns
|
|
5
5
|
* an async iterable of encoded chunks — one per frame — after performing
|
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
type SubscriptionFrame,
|
|
24
24
|
} from '@syncular/core';
|
|
25
25
|
import type { SyncRequestContext } from './context';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
clockOf,
|
|
28
|
+
limitsOf,
|
|
29
|
+
REMOTE_COMMAND_CLIENT_ID_PREFIX,
|
|
30
|
+
RESOLVER_OUTAGE,
|
|
31
|
+
} from './context';
|
|
27
32
|
import { SyncError, syncError } from './errors';
|
|
28
33
|
import {
|
|
29
34
|
emitEvent,
|
|
@@ -215,6 +220,13 @@ async function planRequest(
|
|
|
215
220
|
};
|
|
216
221
|
}
|
|
217
222
|
|
|
223
|
+
if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
224
|
+
throw syncError(
|
|
225
|
+
'sync.invalid_client_id',
|
|
226
|
+
'clientId uses a reserved server-command namespace (§1.5)',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
218
230
|
// §1.5: a clientId already bound to a different actor is rejected.
|
|
219
231
|
const record = await ctx.storage.getClientRecord(
|
|
220
232
|
ctx.partition,
|
|
@@ -629,7 +641,7 @@ async function createStreamCore(
|
|
|
629
641
|
throw error;
|
|
630
642
|
}
|
|
631
643
|
const schema = compileSchema(ctx.schema);
|
|
632
|
-
// Relational row tables
|
|
644
|
+
// Relational row tables: create/
|
|
633
645
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
634
646
|
await ctx.storage.ensureSchema(schema);
|
|
635
647
|
const plan = await planRequest(request, ctx, schema);
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @syncular/server — framework-free embeddable SSP2 protocol library
|
|
3
|
-
* (SPEC.md is normative
|
|
3
|
+
* (SPEC.md is normative).
|
|
4
4
|
*
|
|
5
5
|
* Core surface: `handleSyncRequest(bytes, ctx) → bytes` over host-provided
|
|
6
6
|
* storage / scope-resolution / segment-store interfaces, plus a
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* handler (§5.5), and signed-URL token issuance/verification (§5.4).
|
|
9
9
|
*/
|
|
10
10
|
export * from './admin';
|
|
11
|
+
export * from './authoritative-query';
|
|
11
12
|
export * from './blob-handlers';
|
|
12
13
|
export * from './blob-store';
|
|
13
14
|
export * from './content-encoding';
|
|
@@ -20,6 +21,8 @@ export * from './events-ring';
|
|
|
20
21
|
export * from './frame-bytes';
|
|
21
22
|
export * from './handler';
|
|
22
23
|
export * from './lease-store';
|
|
24
|
+
export * from './operations';
|
|
25
|
+
export * from './operations-realtime';
|
|
23
26
|
// The `PgExecutor` seam + Postgres storage/fanout are driver-agnostic (zero
|
|
24
27
|
// runtime deps). Concrete driver adapters (pglite for tests; Bun.sql /
|
|
25
28
|
// node-postgres for production, documented in the README) live in separate
|
|
@@ -31,6 +34,35 @@ export * from './prune';
|
|
|
31
34
|
export * from './pull';
|
|
32
35
|
export * from './push';
|
|
33
36
|
export * from './readiness';
|
|
37
|
+
export {
|
|
38
|
+
DEFAULT_REACTION_INITIAL_BACKOFF_MS,
|
|
39
|
+
DEFAULT_REACTION_LEASE_MS,
|
|
40
|
+
DEFAULT_REACTION_MAX_ATTEMPTS,
|
|
41
|
+
DEFAULT_REACTION_MAX_BACKOFF_MS,
|
|
42
|
+
DEFAULT_REACTION_RETENTION,
|
|
43
|
+
MAX_REACTION_FAILURE_DETAILS_BYTES,
|
|
44
|
+
MAX_REACTION_PAYLOAD_BYTES,
|
|
45
|
+
MAX_REACTIONS_PER_COMMIT,
|
|
46
|
+
PermanentReactionError,
|
|
47
|
+
pruneReactions,
|
|
48
|
+
ReactionRunner,
|
|
49
|
+
reactionIdempotencyKey,
|
|
50
|
+
retryDeadLetterReaction,
|
|
51
|
+
RetryableReactionError,
|
|
52
|
+
type PlannedReaction,
|
|
53
|
+
type PruneReactionsOptions,
|
|
54
|
+
type ReactionHandler,
|
|
55
|
+
type ReactionHandlerInput,
|
|
56
|
+
type ReactionHandlers,
|
|
57
|
+
type ReactionPlan,
|
|
58
|
+
type ReactionPlanner,
|
|
59
|
+
type ReactionPlannerInput,
|
|
60
|
+
type ReactionPruneResult,
|
|
61
|
+
type ReactionRetentionPolicy,
|
|
62
|
+
type ReactionRunnerOptions,
|
|
63
|
+
type ReactionRunResult,
|
|
64
|
+
type ReactionTypeMap,
|
|
65
|
+
} from './reactions';
|
|
34
66
|
export * from './realtime';
|
|
35
67
|
export * from './relational-rows';
|
|
36
68
|
export * from './s3-blob-store';
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeRemoteOperationRealtimeMessage,
|
|
3
|
+
encodeRemoteOperationRealtimeMessage,
|
|
4
|
+
type RemoteOperationRealtimeMessage,
|
|
5
|
+
} from '@syncular/core';
|
|
6
|
+
import type { RealtimeNotifier, SyncRequestContext } from './context';
|
|
7
|
+
import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context';
|
|
8
|
+
import { SyncError, syncError } from './errors';
|
|
9
|
+
import type {
|
|
10
|
+
RegisteredRemoteQuery,
|
|
11
|
+
RemoteOperationRegistry,
|
|
12
|
+
} from './operations';
|
|
13
|
+
import type { StoredCommit } from './storage';
|
|
14
|
+
|
|
15
|
+
export interface RemoteOperationWatchSession {
|
|
16
|
+
receive(bytes: Uint8Array): Promise<void>;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WatchState {
|
|
21
|
+
readonly watchId: string;
|
|
22
|
+
readonly clientId: string;
|
|
23
|
+
readonly operation: RegisteredRemoteQuery;
|
|
24
|
+
readonly params: unknown;
|
|
25
|
+
running: boolean;
|
|
26
|
+
dirty: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class WatchSession implements RemoteOperationWatchSession {
|
|
30
|
+
readonly #ctx: SyncRequestContext;
|
|
31
|
+
readonly #registry: RemoteOperationRegistry;
|
|
32
|
+
readonly #send: (bytes: Uint8Array) => void;
|
|
33
|
+
readonly #closed: () => void;
|
|
34
|
+
readonly #watches = new Map<string, WatchState>();
|
|
35
|
+
#isClosed = false;
|
|
36
|
+
|
|
37
|
+
get partition(): string {
|
|
38
|
+
return this.#ctx.partition;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
ctx: SyncRequestContext,
|
|
43
|
+
registry: RemoteOperationRegistry,
|
|
44
|
+
send: (bytes: Uint8Array) => void,
|
|
45
|
+
closed: () => void,
|
|
46
|
+
) {
|
|
47
|
+
this.#ctx = ctx;
|
|
48
|
+
this.#registry = registry;
|
|
49
|
+
this.#send = send;
|
|
50
|
+
this.#closed = closed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async receive(bytes: Uint8Array): Promise<void> {
|
|
54
|
+
if (this.#isClosed) return;
|
|
55
|
+
let message: RemoteOperationRealtimeMessage;
|
|
56
|
+
try {
|
|
57
|
+
message = decodeRemoteOperationRealtimeMessage(bytes);
|
|
58
|
+
if (
|
|
59
|
+
typeof message !== 'object' ||
|
|
60
|
+
message === null ||
|
|
61
|
+
message.revision !== 1 ||
|
|
62
|
+
(message.kind !== 'watch' && message.kind !== 'unwatch') ||
|
|
63
|
+
typeof message.watchId !== 'string' ||
|
|
64
|
+
message.watchId.length === 0
|
|
65
|
+
) {
|
|
66
|
+
throw syncError('operation.invalid_request');
|
|
67
|
+
}
|
|
68
|
+
if (message.kind === 'watch') {
|
|
69
|
+
if (
|
|
70
|
+
typeof message.clientId !== 'string' ||
|
|
71
|
+
message.clientId.length === 0 ||
|
|
72
|
+
typeof message.operationId !== 'string' ||
|
|
73
|
+
message.operationId.length === 0
|
|
74
|
+
) {
|
|
75
|
+
throw syncError('operation.invalid_request');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof SyncError) throw error;
|
|
80
|
+
throw syncError('operation.invalid_request');
|
|
81
|
+
}
|
|
82
|
+
if (message.kind === 'unwatch') {
|
|
83
|
+
this.#watches.delete(message.watchId);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (this.#watches.has(message.watchId)) {
|
|
87
|
+
this.#sendError(
|
|
88
|
+
message.watchId,
|
|
89
|
+
syncError('operation.invalid_request', 'watchId is already active'),
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (message.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
94
|
+
this.#sendError(
|
|
95
|
+
message.watchId,
|
|
96
|
+
syncError(
|
|
97
|
+
'sync.invalid_client_id',
|
|
98
|
+
'clientId uses a reserved server-command namespace (§1.5)',
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const clientRecord = await this.#ctx.storage.getClientRecord(
|
|
104
|
+
this.#ctx.partition,
|
|
105
|
+
message.clientId,
|
|
106
|
+
);
|
|
107
|
+
if (
|
|
108
|
+
clientRecord !== undefined &&
|
|
109
|
+
clientRecord.actorId !== this.#ctx.actorId
|
|
110
|
+
) {
|
|
111
|
+
this.#sendError(
|
|
112
|
+
message.watchId,
|
|
113
|
+
syncError(
|
|
114
|
+
'sync.invalid_client_id',
|
|
115
|
+
'clientId is bound to a different actor in this partition (§1.5)',
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const operation = this.#registry.get(message.operationId);
|
|
121
|
+
if (operation === undefined || operation.kind !== 'query') {
|
|
122
|
+
this.#sendError(message.watchId, syncError('operation.unknown'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const state: WatchState = {
|
|
126
|
+
watchId: message.watchId,
|
|
127
|
+
clientId: message.clientId,
|
|
128
|
+
operation,
|
|
129
|
+
params: message.params,
|
|
130
|
+
running: false,
|
|
131
|
+
dirty: false,
|
|
132
|
+
};
|
|
133
|
+
this.#watches.set(message.watchId, state);
|
|
134
|
+
await this.#refresh(state);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
notify(tables: ReadonlySet<string>): void {
|
|
138
|
+
for (const state of this.#watches.values()) {
|
|
139
|
+
if (!state.operation.tables.some((table) => tables.has(table))) continue;
|
|
140
|
+
if (state.running) state.dirty = true;
|
|
141
|
+
else void this.#refresh(state);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async #refresh(state: WatchState): Promise<void> {
|
|
146
|
+
if (this.#isClosed || this.#watches.get(state.watchId) !== state) return;
|
|
147
|
+
if (state.running) {
|
|
148
|
+
state.dirty = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
state.running = true;
|
|
152
|
+
try {
|
|
153
|
+
do {
|
|
154
|
+
state.dirty = false;
|
|
155
|
+
try {
|
|
156
|
+
const response = await state.operation.run(
|
|
157
|
+
this.#ctx,
|
|
158
|
+
state.clientId,
|
|
159
|
+
state.params,
|
|
160
|
+
);
|
|
161
|
+
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (response.kind !== 'query') {
|
|
165
|
+
throw syncError('operation.query_failed');
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
!this.#emit(
|
|
169
|
+
encodeRemoteOperationRealtimeMessage({
|
|
170
|
+
revision: 1,
|
|
171
|
+
kind: 'snapshot',
|
|
172
|
+
watchId: state.watchId,
|
|
173
|
+
operationId: response.operationId,
|
|
174
|
+
rows: response.rows,
|
|
175
|
+
maxCommitSeq: response.maxCommitSeq,
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.#sendError(
|
|
185
|
+
state.watchId,
|
|
186
|
+
error instanceof SyncError
|
|
187
|
+
? error
|
|
188
|
+
: syncError('operation.query_failed'),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
} while (
|
|
192
|
+
state.dirty &&
|
|
193
|
+
!this.#isClosed &&
|
|
194
|
+
this.#watches.get(state.watchId) === state
|
|
195
|
+
);
|
|
196
|
+
} finally {
|
|
197
|
+
state.running = false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#sendError(watchId: string, error: SyncError): void {
|
|
202
|
+
this.#emit(
|
|
203
|
+
encodeRemoteOperationRealtimeMessage({
|
|
204
|
+
revision: 1,
|
|
205
|
+
kind: 'watch_error',
|
|
206
|
+
watchId,
|
|
207
|
+
code: error.code,
|
|
208
|
+
message: error.message,
|
|
209
|
+
retryable: error.retryable,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#emit(bytes: Uint8Array): boolean {
|
|
215
|
+
if (this.#isClosed) return false;
|
|
216
|
+
try {
|
|
217
|
+
this.#send(bytes);
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
this.close();
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
close(): void {
|
|
226
|
+
if (this.#isClosed) return;
|
|
227
|
+
this.#isClosed = true;
|
|
228
|
+
this.#watches.clear();
|
|
229
|
+
this.#closed();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** In-memory invalidation hub. Every notification reruns affected watches. */
|
|
234
|
+
export class RemoteOperationWatchHub implements RealtimeNotifier {
|
|
235
|
+
readonly #registry: RemoteOperationRegistry;
|
|
236
|
+
readonly #sessions = new Set<WatchSession>();
|
|
237
|
+
|
|
238
|
+
constructor(registry: RemoteOperationRegistry) {
|
|
239
|
+
this.#registry = registry;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
connect(
|
|
243
|
+
ctx: SyncRequestContext,
|
|
244
|
+
send: (bytes: Uint8Array) => void,
|
|
245
|
+
): RemoteOperationWatchSession {
|
|
246
|
+
const session = new WatchSession(ctx, this.#registry, send, () => {
|
|
247
|
+
this.#sessions.delete(session);
|
|
248
|
+
});
|
|
249
|
+
this.#sessions.add(session);
|
|
250
|
+
return session;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
notifyCommit(partition: string, commit: StoredCommit): void {
|
|
254
|
+
const tables = new Set(commit.changes.map((change) => change.table));
|
|
255
|
+
for (const session of this.#sessions) {
|
|
256
|
+
if (session.partition === partition) session.notify(tables);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Fan one applied commit into sync deltas and registered query watches. */
|
|
262
|
+
export function composeRealtimeNotifiers(
|
|
263
|
+
...notifiers: readonly RealtimeNotifier[]
|
|
264
|
+
): RealtimeNotifier {
|
|
265
|
+
return {
|
|
266
|
+
notifyCommit: async (partition, commit) => {
|
|
267
|
+
await Promise.all(
|
|
268
|
+
notifiers.map((notifier) => notifier.notifyCommit(partition, commit)),
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|