@syncular/server 0.15.45 → 0.15.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +144 -7
  2. package/dist/admin.d.ts +10 -4
  3. package/dist/admin.js +10 -0
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/context.d.ts +11 -1
  7. package/dist/context.js +2 -0
  8. package/dist/d1-storage.d.ts +10 -1
  9. package/dist/d1-storage.js +216 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.js +43 -1
  12. package/dist/events.d.ts +52 -3
  13. package/dist/handler.js +4 -1
  14. package/dist/index-bun.d.ts +2 -0
  15. package/dist/index-bun.js +2 -0
  16. package/dist/index-node.d.ts +2 -0
  17. package/dist/index-node.js +2 -0
  18. package/dist/index.d.ts +7 -2
  19. package/dist/index.js +7 -6
  20. package/dist/operations-realtime.d.ts +16 -0
  21. package/dist/operations-realtime.js +196 -0
  22. package/dist/operations.d.ts +97 -0
  23. package/dist/operations.js +392 -0
  24. package/dist/postgres-storage.d.ts +11 -2
  25. package/dist/postgres-storage.js +220 -0
  26. package/dist/pull.js +1 -1
  27. package/dist/push.d.ts +8 -2
  28. package/dist/push.js +75 -21
  29. package/dist/reactions.d.ts +167 -0
  30. package/dist/reactions.js +442 -0
  31. package/dist/realtime.js +4 -1
  32. package/dist/sqlite-blob-store.d.ts +4 -9
  33. package/dist/sqlite-blob-store.js +5 -10
  34. package/dist/sqlite-bun-driver.d.ts +11 -0
  35. package/dist/sqlite-bun-driver.js +27 -0
  36. package/dist/sqlite-bun.d.ts +24 -0
  37. package/dist/sqlite-bun.js +40 -0
  38. package/dist/sqlite-dialect.d.ts +8 -8
  39. package/dist/sqlite-dialect.js +22 -2
  40. package/dist/sqlite-driver.d.ts +26 -0
  41. package/dist/sqlite-driver.js +8 -0
  42. package/dist/sqlite-image.d.ts +7 -9
  43. package/dist/sqlite-image.js +26 -28
  44. package/dist/sqlite-lease-store.d.ts +4 -9
  45. package/dist/sqlite-lease-store.js +5 -10
  46. package/dist/sqlite-node-driver.d.ts +10 -0
  47. package/dist/sqlite-node-driver.js +30 -0
  48. package/dist/sqlite-node.d.ts +24 -0
  49. package/dist/sqlite-node.js +50 -0
  50. package/dist/sqlite-segment-store.d.ts +4 -10
  51. package/dist/sqlite-segment-store.js +6 -9
  52. package/dist/sqlite-storage.d.ts +13 -12
  53. package/dist/sqlite-storage.js +223 -5
  54. package/dist/storage-errors.js +4 -1
  55. package/dist/storage.d.ts +109 -0
  56. package/dist/validate.js +1 -0
  57. package/package.json +18 -3
  58. package/src/admin.ts +27 -3
  59. package/src/authoritative-query.ts +218 -0
  60. package/src/context.ts +12 -1
  61. package/src/d1-storage.ts +352 -0
  62. package/src/errors.ts +43 -1
  63. package/src/events.ts +64 -2
  64. package/src/handler.ts +13 -1
  65. package/src/index-bun.ts +9 -0
  66. package/src/index-node.ts +9 -0
  67. package/src/index.ts +40 -6
  68. package/src/operations-realtime.ts +272 -0
  69. package/src/operations.ts +720 -0
  70. package/src/postgres-storage.ts +351 -0
  71. package/src/pull.ts +1 -1
  72. package/src/push.ts +97 -29
  73. package/src/reactions.ts +741 -0
  74. package/src/realtime.ts +7 -1
  75. package/src/sqlite-blob-store.ts +11 -10
  76. package/src/sqlite-bun-driver.ts +42 -0
  77. package/src/sqlite-bun.ts +53 -0
  78. package/src/sqlite-dialect.ts +27 -7
  79. package/src/sqlite-driver.ts +44 -0
  80. package/src/sqlite-image.ts +44 -49
  81. package/src/sqlite-lease-store.ts +11 -10
  82. package/src/sqlite-node-driver.ts +46 -0
  83. package/src/sqlite-node.ts +62 -0
  84. package/src/sqlite-segment-store.ts +11 -11
  85. package/src/sqlite-storage.ts +378 -7
  86. package/src/storage-errors.ts +4 -1
  87. package/src/storage.ts +165 -0
  88. 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 (21 sync.* + 4 blob.* codes), keyed by stable code. */
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.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Structured server events — the one operational seam (host surface, not
3
- * wire protocol; SPEC.md is untouched by this module).
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
@@ -23,7 +23,12 @@ import {
23
23
  type SubscriptionFrame,
24
24
  } from '@syncular/core';
25
25
  import type { SyncRequestContext } from './context';
26
- import { clockOf, limitsOf, RESOLVER_OUTAGE } from './context';
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,
@@ -0,0 +1,9 @@
1
+ export * from './index';
2
+ export {
3
+ buildSqliteImage,
4
+ BunSqliteDatabase,
5
+ SqliteBlobStore,
6
+ SqliteLeaseStore,
7
+ SqliteSegmentStore,
8
+ SqliteServerStorage,
9
+ } from './sqlite-bun';
@@ -0,0 +1,9 @@
1
+ export * from './index';
2
+ export {
3
+ buildSqliteImage,
4
+ NodeSqliteDatabase,
5
+ SqliteBlobStore,
6
+ SqliteLeaseStore,
7
+ SqliteSegmentStore,
8
+ SqliteServerStorage,
9
+ } from './sqlite-node';
package/src/index.ts CHANGED
@@ -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';
@@ -41,13 +73,15 @@ export * from './seed';
41
73
  export * from './segment-download';
42
74
  export * from './segment-store';
43
75
  export * from './signed-url';
44
- // Bun-specific storages (top-level `bun:sqlite`): re-exported for Bun/Node
45
- // hosts. Workers/edge builds tree-shake them (and their `bun:sqlite` import)
46
- // away — the runtime-neutral core closure is enforced by
47
- // `test/runtime-neutrality.test.ts`.
48
- export * from './sqlite-blob-store';
49
76
  export * from './sqlite-dialect';
50
- export * from './sqlite-image';
77
+ export * from './sqlite-driver';
78
+ export {
79
+ IMAGE_METADATA_TABLE,
80
+ IMAGE_VERSION_COLUMN,
81
+ type SqliteImageBuilder,
82
+ type SqliteImageInput,
83
+ } from './sqlite-image';
84
+ export * from './sqlite-blob-store';
51
85
  export * from './sqlite-lease-store';
52
86
  export * from './sqlite-segment-store';
53
87
  export * from './sqlite-storage';
@@ -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
+ }