@syncular/relay 0.0.1-60

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 (68) hide show
  1. package/dist/client-role/forward-engine.d.ts +63 -0
  2. package/dist/client-role/forward-engine.d.ts.map +1 -0
  3. package/dist/client-role/forward-engine.js +263 -0
  4. package/dist/client-role/forward-engine.js.map +1 -0
  5. package/dist/client-role/index.d.ts +9 -0
  6. package/dist/client-role/index.d.ts.map +1 -0
  7. package/dist/client-role/index.js +9 -0
  8. package/dist/client-role/index.js.map +1 -0
  9. package/dist/client-role/pull-engine.d.ts +70 -0
  10. package/dist/client-role/pull-engine.d.ts.map +1 -0
  11. package/dist/client-role/pull-engine.js +233 -0
  12. package/dist/client-role/pull-engine.js.map +1 -0
  13. package/dist/client-role/sequence-mapper.d.ts +65 -0
  14. package/dist/client-role/sequence-mapper.d.ts.map +1 -0
  15. package/dist/client-role/sequence-mapper.js +161 -0
  16. package/dist/client-role/sequence-mapper.js.map +1 -0
  17. package/dist/index.d.ts +37 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +44 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/migrate.d.ts +18 -0
  22. package/dist/migrate.d.ts.map +1 -0
  23. package/dist/migrate.js +99 -0
  24. package/dist/migrate.js.map +1 -0
  25. package/dist/mode-manager.d.ts +60 -0
  26. package/dist/mode-manager.d.ts.map +1 -0
  27. package/dist/mode-manager.js +114 -0
  28. package/dist/mode-manager.js.map +1 -0
  29. package/dist/realtime.d.ts +102 -0
  30. package/dist/realtime.d.ts.map +1 -0
  31. package/dist/realtime.js +305 -0
  32. package/dist/realtime.js.map +1 -0
  33. package/dist/relay.d.ts +188 -0
  34. package/dist/relay.d.ts.map +1 -0
  35. package/dist/relay.js +315 -0
  36. package/dist/relay.js.map +1 -0
  37. package/dist/schema.d.ts +158 -0
  38. package/dist/schema.d.ts.map +1 -0
  39. package/dist/schema.js +7 -0
  40. package/dist/schema.js.map +1 -0
  41. package/dist/server-role/index.d.ts +54 -0
  42. package/dist/server-role/index.d.ts.map +1 -0
  43. package/dist/server-role/index.js +198 -0
  44. package/dist/server-role/index.js.map +1 -0
  45. package/dist/server-role/pull.d.ts +25 -0
  46. package/dist/server-role/pull.d.ts.map +1 -0
  47. package/dist/server-role/pull.js +24 -0
  48. package/dist/server-role/pull.js.map +1 -0
  49. package/dist/server-role/push.d.ts +27 -0
  50. package/dist/server-role/push.d.ts.map +1 -0
  51. package/dist/server-role/push.js +98 -0
  52. package/dist/server-role/push.js.map +1 -0
  53. package/package.json +61 -0
  54. package/src/__tests__/relay.test.ts +464 -0
  55. package/src/bun-types.d.ts +50 -0
  56. package/src/client-role/forward-engine.ts +352 -0
  57. package/src/client-role/index.ts +9 -0
  58. package/src/client-role/pull-engine.ts +301 -0
  59. package/src/client-role/sequence-mapper.ts +201 -0
  60. package/src/index.ts +50 -0
  61. package/src/migrate.ts +113 -0
  62. package/src/mode-manager.ts +142 -0
  63. package/src/realtime.ts +370 -0
  64. package/src/relay.ts +421 -0
  65. package/src/schema.ts +171 -0
  66. package/src/server-role/index.ts +342 -0
  67. package/src/server-role/pull.ts +37 -0
  68. package/src/server-role/push.ts +130 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * @syncular/relay - Forward Engine
3
+ *
4
+ * Forwards commits from the relay's local outbox to the main server.
5
+ * Preserves original client_id + client_commit_id for idempotency.
6
+ */
7
+
8
+ import type {
9
+ SyncOperation,
10
+ SyncPushResponse,
11
+ SyncTransport,
12
+ } from '@syncular/core';
13
+ import type { Kysely } from 'kysely';
14
+ import { sql } from 'kysely';
15
+ import type {
16
+ ForwardConflictEntry,
17
+ ForwardOutboxEntry,
18
+ RelayDatabase,
19
+ RelayForwardOutboxStatus,
20
+ } from '../schema';
21
+ import type { SequenceMapper } from './sequence-mapper';
22
+
23
+ function randomId(): string {
24
+ if (
25
+ typeof crypto !== 'undefined' &&
26
+ typeof crypto.randomUUID === 'function'
27
+ ) {
28
+ return crypto.randomUUID();
29
+ }
30
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
31
+ }
32
+
33
+ /**
34
+ * Forward engine options.
35
+ */
36
+ export interface ForwardEngineOptions<
37
+ DB extends RelayDatabase = RelayDatabase,
38
+ > {
39
+ db: Kysely<DB>;
40
+ transport: SyncTransport;
41
+ clientId: string;
42
+ sequenceMapper: SequenceMapper<DB>;
43
+ retryIntervalMs?: number;
44
+ onConflict?: (conflict: ForwardConflictEntry) => void;
45
+ onError?: (error: Error) => void;
46
+ }
47
+
48
+ /**
49
+ * Forward engine for sending local commits to the main server.
50
+ */
51
+ export class ForwardEngine<DB extends RelayDatabase = RelayDatabase> {
52
+ private readonly db: Kysely<DB>;
53
+ private readonly transport: SyncTransport;
54
+ private readonly clientId: string;
55
+ private readonly sequenceMapper: SequenceMapper<DB>;
56
+ private readonly retryIntervalMs: number;
57
+ private readonly onConflict?: (conflict: ForwardConflictEntry) => void;
58
+ private readonly onError?: (error: Error) => void;
59
+
60
+ private running = false;
61
+ private timer: ReturnType<typeof setTimeout> | null = null;
62
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: assigned in scheduleWakeUp and read in tick
63
+ private wakeUpRequested = false;
64
+
65
+ constructor(options: ForwardEngineOptions<DB>) {
66
+ this.db = options.db;
67
+ this.transport = options.transport;
68
+ this.clientId = options.clientId;
69
+ this.sequenceMapper = options.sequenceMapper;
70
+ this.retryIntervalMs = options.retryIntervalMs ?? 5000;
71
+ this.onConflict = options.onConflict;
72
+ this.onError = options.onError;
73
+ }
74
+
75
+ /**
76
+ * Start the forward engine loop.
77
+ */
78
+ start(): void {
79
+ if (this.running) return;
80
+ this.running = true;
81
+ this.scheduleNext(0);
82
+ }
83
+
84
+ /**
85
+ * Stop the forward engine.
86
+ */
87
+ stop(): void {
88
+ this.running = false;
89
+ if (this.timer) {
90
+ clearTimeout(this.timer);
91
+ this.timer = null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Wake up the engine to process immediately.
97
+ */
98
+ wakeUp(): void {
99
+ if (!this.running) return;
100
+ this.wakeUpRequested = true;
101
+ if (this.timer) {
102
+ clearTimeout(this.timer);
103
+ this.timer = null;
104
+ }
105
+ this.scheduleNext(0);
106
+ }
107
+
108
+ /**
109
+ * Forward a single commit (for manual/testing use).
110
+ */
111
+ async forwardOnce(): Promise<boolean> {
112
+ return this.processOne();
113
+ }
114
+
115
+ private scheduleNext(delayMs: number): void {
116
+ if (!this.running) return;
117
+ if (this.timer) return;
118
+
119
+ this.timer = setTimeout(async () => {
120
+ this.timer = null;
121
+ this.wakeUpRequested = false;
122
+
123
+ try {
124
+ const forwarded = await this.processOne();
125
+ // If we forwarded something, immediately try again
126
+ const nextDelay = forwarded ? 0 : this.retryIntervalMs;
127
+ this.scheduleNext(nextDelay);
128
+ } catch (err) {
129
+ this.onError?.(err instanceof Error ? err : new Error(String(err)));
130
+ this.scheduleNext(this.retryIntervalMs);
131
+ }
132
+ }, delayMs);
133
+ }
134
+
135
+ private async processOne(): Promise<boolean> {
136
+ const next = await this.getNextSendable();
137
+ if (!next) return false;
138
+
139
+ await this.markSending(next.id);
140
+
141
+ let response: SyncPushResponse;
142
+ try {
143
+ const combined = await this.transport.sync({
144
+ clientId: next.client_id,
145
+ push: {
146
+ clientCommitId: next.client_commit_id,
147
+ operations: next.operations,
148
+ schemaVersion: next.schema_version,
149
+ },
150
+ });
151
+ if (!combined.push) {
152
+ throw new Error('Server returned no push response');
153
+ }
154
+ response = combined.push;
155
+ } catch (err) {
156
+ // Network error - mark as pending for retry
157
+ await this.markPending(next.id, String(err));
158
+ throw err;
159
+ }
160
+
161
+ const responseJson = JSON.stringify(response);
162
+
163
+ if (response.status === 'applied' || response.status === 'cached') {
164
+ const mainCommitSeq = response.commitSeq ?? null;
165
+
166
+ // Update outbox entry
167
+ await this.markForwarded(next.id, mainCommitSeq, responseJson);
168
+
169
+ // Update sequence mapper
170
+ if (mainCommitSeq != null) {
171
+ await this.sequenceMapper.markForwarded(
172
+ next.local_commit_seq,
173
+ mainCommitSeq
174
+ );
175
+ }
176
+
177
+ return true;
178
+ }
179
+
180
+ // Rejected - store conflict and mark as failed
181
+ const conflict = await this.recordConflict(next, responseJson);
182
+ await this.markFailed(next.id, 'REJECTED', responseJson);
183
+
184
+ this.onConflict?.(conflict);
185
+
186
+ return true;
187
+ }
188
+
189
+ private async getNextSendable(): Promise<ForwardOutboxEntry | null> {
190
+ const staleThreshold = Date.now() - 30000;
191
+
192
+ const rowResult = await sql<{
193
+ id: string;
194
+ local_commit_seq: number;
195
+ client_id: string;
196
+ client_commit_id: string;
197
+ operations_json: string;
198
+ schema_version: number;
199
+ status: RelayForwardOutboxStatus;
200
+ main_commit_seq: number | null;
201
+ error: string | null;
202
+ last_response_json: string | null;
203
+ created_at: number;
204
+ updated_at: number;
205
+ attempt_count: number;
206
+ }>`
207
+ select
208
+ id,
209
+ local_commit_seq,
210
+ client_id,
211
+ client_commit_id,
212
+ operations_json,
213
+ schema_version,
214
+ status,
215
+ main_commit_seq,
216
+ error,
217
+ last_response_json,
218
+ created_at,
219
+ updated_at,
220
+ attempt_count
221
+ from ${sql.table('relay_forward_outbox')}
222
+ where
223
+ status = 'pending'
224
+ or (status = 'forwarding' and updated_at < ${staleThreshold})
225
+ order by created_at asc
226
+ limit 1
227
+ `.execute(this.db);
228
+ const row = rowResult.rows[0];
229
+
230
+ if (!row) return null;
231
+
232
+ const operations =
233
+ typeof row.operations_json === 'string'
234
+ ? (JSON.parse(row.operations_json) as SyncOperation[])
235
+ : (row.operations_json as SyncOperation[]);
236
+
237
+ return {
238
+ id: row.id,
239
+ local_commit_seq: row.local_commit_seq,
240
+ client_id: row.client_id,
241
+ client_commit_id: row.client_commit_id,
242
+ operations,
243
+ schema_version: row.schema_version,
244
+ status: row.status,
245
+ main_commit_seq: row.main_commit_seq,
246
+ error: row.error,
247
+ created_at: row.created_at,
248
+ updated_at: row.updated_at,
249
+ attempt_count: row.attempt_count,
250
+ };
251
+ }
252
+
253
+ private async markSending(id: string): Promise<void> {
254
+ const now = Date.now();
255
+
256
+ await sql`
257
+ update ${sql.table('relay_forward_outbox')}
258
+ set
259
+ status = 'forwarding',
260
+ updated_at = ${now},
261
+ attempt_count = attempt_count + 1,
262
+ error = ${null}
263
+ where id = ${id}
264
+ `.execute(this.db);
265
+ }
266
+
267
+ private async markPending(id: string, error: string): Promise<void> {
268
+ const now = Date.now();
269
+
270
+ await sql`
271
+ update ${sql.table('relay_forward_outbox')}
272
+ set status = 'pending', updated_at = ${now}, error = ${error}
273
+ where id = ${id}
274
+ `.execute(this.db);
275
+ }
276
+
277
+ private async markForwarded(
278
+ id: string,
279
+ mainCommitSeq: number | null,
280
+ responseJson: string
281
+ ): Promise<void> {
282
+ const now = Date.now();
283
+
284
+ await sql`
285
+ update ${sql.table('relay_forward_outbox')}
286
+ set
287
+ status = 'forwarded',
288
+ main_commit_seq = ${mainCommitSeq},
289
+ updated_at = ${now},
290
+ error = ${null},
291
+ last_response_json = ${responseJson}
292
+ where id = ${id}
293
+ `.execute(this.db);
294
+ }
295
+
296
+ private async markFailed(
297
+ id: string,
298
+ error: string,
299
+ responseJson: string
300
+ ): Promise<void> {
301
+ const now = Date.now();
302
+
303
+ await sql`
304
+ update ${sql.table('relay_forward_outbox')}
305
+ set
306
+ status = 'failed',
307
+ updated_at = ${now},
308
+ error = ${error},
309
+ last_response_json = ${responseJson}
310
+ where id = ${id}
311
+ `.execute(this.db);
312
+ }
313
+
314
+ private async recordConflict(
315
+ entry: ForwardOutboxEntry,
316
+ responseJson: string
317
+ ): Promise<ForwardConflictEntry> {
318
+ const now = Date.now();
319
+ const id = randomId();
320
+
321
+ await sql`
322
+ insert into ${sql.table('relay_forward_conflicts')} (
323
+ id,
324
+ local_commit_seq,
325
+ client_id,
326
+ client_commit_id,
327
+ response_json,
328
+ created_at,
329
+ resolved_at
330
+ )
331
+ values (
332
+ ${id},
333
+ ${entry.local_commit_seq},
334
+ ${entry.client_id},
335
+ ${entry.client_commit_id},
336
+ ${responseJson},
337
+ ${now},
338
+ ${null}
339
+ )
340
+ `.execute(this.db);
341
+
342
+ return {
343
+ id,
344
+ local_commit_seq: entry.local_commit_seq,
345
+ client_id: entry.client_id,
346
+ client_commit_id: entry.client_commit_id,
347
+ response: JSON.parse(responseJson),
348
+ created_at: now,
349
+ resolved_at: null,
350
+ };
351
+ }
352
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @syncular/relay - Client Role
3
+ *
4
+ * Components for the relay's role as a client to the main server.
5
+ */
6
+
7
+ export * from './forward-engine';
8
+ export * from './pull-engine';
9
+ export * from './sequence-mapper';
@@ -0,0 +1,301 @@
1
+ /**
2
+ * @syncular/relay - Pull Engine
3
+ *
4
+ * Pulls changes from the main server and stores them locally
5
+ * on the relay for local clients to access.
6
+ */
7
+
8
+ import type {
9
+ ScopeValues,
10
+ SyncCommit,
11
+ SyncPullResponse,
12
+ SyncSubscriptionRequest,
13
+ SyncTransport,
14
+ } from '@syncular/core';
15
+ import type { ServerSyncDialect, TableRegistry } from '@syncular/server';
16
+ import { pushCommit } from '@syncular/server';
17
+ import { type Kysely, sql } from 'kysely';
18
+ import type { RelayRealtime } from '../realtime';
19
+ import type { RelayDatabase } from '../schema';
20
+ import type { SequenceMapper } from './sequence-mapper';
21
+
22
+ /**
23
+ * Pull engine options.
24
+ */
25
+ export interface PullEngineOptions<DB extends RelayDatabase = RelayDatabase> {
26
+ db: Kysely<DB>;
27
+ dialect: ServerSyncDialect;
28
+ transport: SyncTransport;
29
+ clientId: string;
30
+ /** Tables to subscribe to */
31
+ tables: string[];
32
+ /** Scope values for subscriptions */
33
+ scopes: ScopeValues;
34
+ shapes: TableRegistry<DB>;
35
+ sequenceMapper: SequenceMapper<DB>;
36
+ realtime: RelayRealtime;
37
+ intervalMs?: number;
38
+ onError?: (error: Error) => void;
39
+ onPullComplete?: () => Promise<void>;
40
+ }
41
+
42
+ /**
43
+ * Pull engine for receiving changes from the main server.
44
+ */
45
+ export class PullEngine<DB extends RelayDatabase = RelayDatabase> {
46
+ private readonly db: Kysely<DB>;
47
+ private readonly dialect: ServerSyncDialect;
48
+ private readonly transport: SyncTransport;
49
+ private readonly clientId: string;
50
+ private readonly tables: string[];
51
+ private readonly scopes: ScopeValues;
52
+ private readonly shapes: TableRegistry<DB>;
53
+ private readonly sequenceMapper: SequenceMapper<DB>;
54
+ private readonly realtime: RelayRealtime;
55
+ private readonly intervalMs: number;
56
+ private readonly onError?: (error: Error) => void;
57
+ private readonly onPullComplete?: () => Promise<void>;
58
+
59
+ private running = false;
60
+ private timer: ReturnType<typeof setTimeout> | null = null;
61
+ private cursors = new Map<string, number>();
62
+
63
+ constructor(options: PullEngineOptions<DB>) {
64
+ this.db = options.db;
65
+ this.dialect = options.dialect;
66
+ this.transport = options.transport;
67
+ this.clientId = options.clientId;
68
+ this.tables = options.tables;
69
+ this.scopes = options.scopes;
70
+ this.shapes = options.shapes;
71
+ this.sequenceMapper = options.sequenceMapper;
72
+ this.realtime = options.realtime;
73
+ this.intervalMs = options.intervalMs ?? 10000;
74
+ this.onError = options.onError;
75
+ this.onPullComplete = options.onPullComplete;
76
+ }
77
+
78
+ /**
79
+ * Start the pull engine loop.
80
+ */
81
+ start(): void {
82
+ if (this.running) return;
83
+ this.running = true;
84
+ this.loadCursors().then(() => {
85
+ this.scheduleNext(0);
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Stop the pull engine.
91
+ */
92
+ stop(): void {
93
+ this.running = false;
94
+ if (this.timer) {
95
+ clearTimeout(this.timer);
96
+ this.timer = null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Pull once (for manual/testing use).
102
+ */
103
+ async pullOnce(): Promise<boolean> {
104
+ return this.processOne();
105
+ }
106
+
107
+ private async loadCursors(): Promise<void> {
108
+ try {
109
+ // Load cursors from config
110
+ const rowResult = await sql<{ value_json: string }>`
111
+ select value_json
112
+ from ${sql.table('relay_config')}
113
+ where key = 'main_cursors'
114
+ limit 1
115
+ `.execute(this.db);
116
+ const row = rowResult.rows[0];
117
+
118
+ if (row?.value_json) {
119
+ const parsed = JSON.parse(row.value_json);
120
+ if (typeof parsed === 'object' && parsed !== null) {
121
+ for (const [key, value] of Object.entries(parsed)) {
122
+ if (typeof value === 'number') {
123
+ this.cursors.set(key, value);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ } catch {
129
+ // Ignore - start from scratch
130
+ }
131
+ }
132
+
133
+ private async saveCursors(): Promise<void> {
134
+ const cursorObj: Record<string, number> = {};
135
+ for (const [key, value] of this.cursors) {
136
+ cursorObj[key] = value;
137
+ }
138
+
139
+ const valueJson = JSON.stringify(cursorObj);
140
+ await sql`
141
+ insert into ${sql.table('relay_config')} (key, value_json)
142
+ values ('main_cursors', ${valueJson})
143
+ on conflict (key)
144
+ do update set value_json = ${valueJson}
145
+ `.execute(this.db);
146
+ }
147
+
148
+ private scheduleNext(delayMs: number): void {
149
+ if (!this.running) return;
150
+ if (this.timer) return;
151
+
152
+ this.timer = setTimeout(async () => {
153
+ this.timer = null;
154
+
155
+ try {
156
+ const pulled = await this.processOne();
157
+ // If we pulled something, immediately try again
158
+ const nextDelay = pulled ? 0 : this.intervalMs;
159
+ this.scheduleNext(nextDelay);
160
+ } catch (err) {
161
+ this.onError?.(err instanceof Error ? err : new Error(String(err)));
162
+ this.scheduleNext(this.intervalMs);
163
+ }
164
+ }, delayMs);
165
+ }
166
+
167
+ private async processOne(): Promise<boolean> {
168
+ // Build subscriptions for each table
169
+ const subscriptionRequests: SyncSubscriptionRequest[] = this.tables.map(
170
+ (table) => ({
171
+ id: table,
172
+ shape: table,
173
+ scopes: this.scopes,
174
+ cursor: this.cursors.get(table) ?? -1,
175
+ })
176
+ );
177
+
178
+ let response: SyncPullResponse;
179
+ try {
180
+ const combined = await this.transport.sync({
181
+ clientId: this.clientId,
182
+ pull: {
183
+ subscriptions: subscriptionRequests,
184
+ limitCommits: 100,
185
+ },
186
+ });
187
+ if (!combined.pull) {
188
+ return false;
189
+ }
190
+ response = combined.pull;
191
+ } catch {
192
+ // Network error - will retry
193
+ return false;
194
+ }
195
+
196
+ if (!response.ok) {
197
+ return false;
198
+ }
199
+
200
+ let hasChanges = false;
201
+ const affectedTables = new Set<string>();
202
+
203
+ for (const sub of response.subscriptions) {
204
+ if (sub.status !== 'active') continue;
205
+
206
+ const table = sub.id;
207
+
208
+ // Process commits
209
+ for (const commit of sub.commits) {
210
+ const applied = await this.applyCommitLocally(commit, table);
211
+ if (applied) {
212
+ hasChanges = true;
213
+ affectedTables.add(table);
214
+ }
215
+ }
216
+
217
+ // Update cursor
218
+ if (sub.nextCursor > (this.cursors.get(table) ?? -1)) {
219
+ this.cursors.set(table, sub.nextCursor);
220
+ }
221
+ }
222
+
223
+ // Save updated cursors
224
+ await this.saveCursors();
225
+
226
+ // Notify local clients if we have changes
227
+ if (hasChanges && affectedTables.size > 0) {
228
+ const maxCursor = await this.dialect.readMaxCommitSeq(this.db);
229
+ this.realtime.notifyScopeKeys(Array.from(affectedTables), maxCursor);
230
+ }
231
+
232
+ // Trigger rate-limited prune after successful pull
233
+ await this.onPullComplete?.();
234
+
235
+ return hasChanges;
236
+ }
237
+
238
+ /**
239
+ * Apply a commit from main server locally.
240
+ *
241
+ * This re-applies the commit through the local shape handlers
242
+ * to ensure proper indexing and scope assignment.
243
+ */
244
+ private async applyCommitLocally(
245
+ commit: SyncCommit,
246
+ table: string
247
+ ): Promise<boolean> {
248
+ if (commit.changes.length === 0) return false;
249
+
250
+ // Convert changes to operations
251
+ const operations = commit.changes.map((change) => ({
252
+ table: change.table,
253
+ row_id: change.row_id,
254
+ op: change.op,
255
+ payload: change.row_json as Record<string, unknown> | null,
256
+ }));
257
+
258
+ // Generate a unique commit ID for this relay instance
259
+ const relayCommitId = `main:${commit.commitSeq}:${table}`;
260
+
261
+ // Push through local handler
262
+ const result = await pushCommit({
263
+ db: this.db,
264
+ dialect: this.dialect,
265
+ shapes: this.shapes,
266
+ actorId: commit.actorId,
267
+ request: {
268
+ clientId: `relay:${this.clientId}`,
269
+ clientCommitId: relayCommitId,
270
+ operations,
271
+ schemaVersion: 1,
272
+ },
273
+ });
274
+
275
+ if (
276
+ result.response.ok === true &&
277
+ result.response.status === 'applied' &&
278
+ typeof result.response.commitSeq === 'number'
279
+ ) {
280
+ // Record sequence mapping
281
+ await this.sequenceMapper.createConfirmedMapping(
282
+ result.response.commitSeq,
283
+ commit.commitSeq
284
+ );
285
+ return true;
286
+ }
287
+
288
+ // Already applied (cached) - that's fine
289
+ if (result.response.status === 'cached') {
290
+ return false;
291
+ }
292
+
293
+ // Rejected - this shouldn't happen for pulls from main
294
+ // Log but don't fail
295
+ console.warn(
296
+ `Relay: Failed to apply commit ${commit.commitSeq} locally:`,
297
+ result.response
298
+ );
299
+ return false;
300
+ }
301
+ }