@kubun/plugin-connector 0.9.0 → 0.11.0

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/lib/schema.js CHANGED
@@ -1,4 +1,90 @@
1
- import{PluginNodeID as t}from"@kubun/id";export function toConnectorSyncEventResult(t){return{type:"started"===t.type?"STARTED":"progress"===t.type?"PROGRESS":"completed"===t.type?"COMPLETED":"ERROR",connectorName:t.connectorName,entitiesProcessed:t.entitiesProcessed??null,entitiesFailed:t.entitiesFailed??null,totalProcessed:t.totalProcessed??null,totalFailed:t.totalFailed??null,duration:t.duration??null,error:t.error??null}}function e(t){if(null==t.connector)throw Error("connector plugin is not wired into this context");return t.connector}function n(t){let e=[];for(let n of Object.values(t.clusters))for(let t of Object.values(n.record)){let o=n.models[t];o?.name==null||e.includes(o.name)||e.push(o.name)}return e}export function createConnectorSchemaExtension(o){let r=o.registry,c=[];c.push(`
1
+ import { PluginNodeID } from '@kubun/id';
2
+ import { fromEmitter } from '@sozai/generator';
3
+ /**
4
+ * Transform a raw `ConnectorSyncEventPayload` into the GraphQL result shape.
5
+ */ export function toConnectorSyncEventResult(event) {
6
+ return {
7
+ type: event.type === 'started' ? 'STARTED' : event.type === 'progress' ? 'PROGRESS' : event.type === 'completed' ? 'COMPLETED' : 'ERROR',
8
+ connectorName: event.connectorName,
9
+ entitiesProcessed: event.entitiesProcessed ?? null,
10
+ entitiesFailed: event.entitiesFailed ?? null,
11
+ totalProcessed: event.totalProcessed ?? null,
12
+ totalFailed: event.totalFailed ?? null,
13
+ duration: event.duration ?? null,
14
+ error: event.error ?? null
15
+ };
16
+ }
17
+ /**
18
+ * Subscribe to a connector sync-event emitter, yielding GraphQL result shapes.
19
+ *
20
+ * Implemented as a manual async iterator rather than an `async function*` wrapper
21
+ * so that a consumer's `return()` forwards directly to the underlying emitter
22
+ * generator, removing its listener immediately. A `for await` wrapper instead
23
+ * queues the `return()` behind a parked `next()` between events, so the emitter
24
+ * listener would leak until the next event arrives — for an idle subscription,
25
+ * potentially never.
26
+ */ export function subscribeToConnectorSyncEvents(emitter, connector) {
27
+ const filter = connector != null ? (event)=>event.connectorName === connector : undefined;
28
+ const generator = fromEmitter(emitter, 'sync', {
29
+ filter
30
+ });
31
+ return {
32
+ [Symbol.asyncIterator] () {
33
+ return this;
34
+ },
35
+ async next () {
36
+ const { done, value } = await generator.next();
37
+ return done ? {
38
+ done: true,
39
+ value: undefined
40
+ } : {
41
+ done: false,
42
+ value: toConnectorSyncEventResult(value)
43
+ };
44
+ },
45
+ async return () {
46
+ await generator.return();
47
+ return {
48
+ done: true,
49
+ value: undefined
50
+ };
51
+ },
52
+ async throw (reason) {
53
+ await generator.throw(reason);
54
+ return {
55
+ done: true,
56
+ value: undefined
57
+ };
58
+ }
59
+ };
60
+ }
61
+ function requireConnector(ctx) {
62
+ if (ctx.connector == null) {
63
+ throw new Error('connector plugin is not wired into this context');
64
+ }
65
+ return ctx.connector;
66
+ }
67
+ /**
68
+ * Extract model names from a connector's clusters.
69
+ * Each cluster has `record` (modelID → index) and `models` (array of model defs).
70
+ */ function getConnectorModelNames(connector) {
71
+ const names = [];
72
+ for (const cluster of Object.values(connector.clusters)){
73
+ for (const index of Object.values(cluster.record)){
74
+ const model = cluster.models[index];
75
+ if (model?.name != null && !names.includes(model.name)) {
76
+ names.push(model.name);
77
+ }
78
+ }
79
+ }
80
+ return names;
81
+ }
82
+ export function createConnectorSchemaExtension(params) {
83
+ const registry = params.registry;
84
+ // ---- Build SDL ----
85
+ const sdlParts = [];
86
+ // Core types
87
+ sdlParts.push(`
2
88
  enum ConnectorSyncStatus {
3
89
  IDLE
4
90
  SYNCING
@@ -62,35 +148,236 @@ type ConnectorActionError {
62
148
  requiredScopes: [String!]
63
149
  }
64
150
 
151
+ type ConnectorWriteGrant {
152
+ connector: String!
153
+ provider: String!
154
+ modelURNs: [String!]!
155
+ exp: Int!
156
+ jti: String!
157
+ }
158
+
65
159
  extend type Query {
66
160
  connector(name: String!): Connector!
67
161
  connectors: [Connector!]!
162
+ connectorWriteGrants: [ConnectorWriteGrant!]!
68
163
  }
69
164
 
70
165
  extend type Mutation {
71
166
  startConnectorAuth(provider: String!, redirectURL: String!, connectors: [String!], requestWriteAccess: Boolean): StartConnectorAuthResult!
72
- completeConnectorAuth(provider: String!, code: String!, redirectURL: String!, state: String): CompleteConnectorAuthResult!
167
+ completeConnectorAuth(provider: String!, code: String!, redirectURL: String!, state: String!): CompleteConnectorAuthResult!
73
168
  syncConnector(connector: String!, full: Boolean): SyncTriggerResult!
169
+ grantConnectorWriteCapability(connector: String!, tokens: [String!]!): Boolean!
170
+ revokeConnectorWriteCapability(connector: String!): Boolean!
171
+ disconnectProvider(provider: String!): Boolean!
74
172
  }
75
173
 
76
174
  extend type Subscription {
77
175
  connectorSyncEvents(connector: String): ConnectorSyncEvent!
78
176
  }
79
- `);let i=[];if(null!=r){let t=new Set;for(let e of r.getAll()){if(null==e.actions||0===e.actions.length)continue;let o=e.actions.some(t=>"create"===t.name),r=e.actions.some(t=>"update"===t.name);if(o||r)for(let s of n(e).filter(t=>"ExternalEntity"!==t))t.has(s)||(t.add(s),c.push(`
80
- type Connector${s}Payload {
177
+ `);
178
+ // Dynamic per-model action types and mutations from registered connectors
179
+ const dynamicMutationLines = [];
180
+ if (registry != null) {
181
+ const payloadTypesDefined = new Set();
182
+ for (const connector of registry.getAll()){
183
+ if (connector.actions == null || connector.actions.length === 0) {
184
+ continue;
185
+ }
186
+ const hasCreate = connector.actions.some((a)=>a.name === 'create');
187
+ const hasUpdate = connector.actions.some((a)=>a.name === 'update');
188
+ if (!hasCreate && !hasUpdate) {
189
+ continue;
190
+ }
191
+ const modelNames = getConnectorModelNames(connector).filter((name)=>name !== 'ExternalEntity');
192
+ for (const modelName of modelNames){
193
+ // Define payload type once per model
194
+ if (!payloadTypesDefined.has(modelName)) {
195
+ payloadTypesDefined.add(modelName);
196
+ sdlParts.push(`
197
+ type Connector${modelName}Payload {
81
198
  documentID: String
82
199
  entity: JSON
83
200
  error: ConnectorActionError
84
201
  }
85
- `)),o&&(c.push(`
86
- input ConnectorCreate${s}Input {
87
- input: JSON!
202
+ `);
203
+ }
204
+ if (hasCreate) {
205
+ sdlParts.push(`
206
+ input ConnectorCreate${modelName}Input {
207
+ data: JSON!
88
208
  }
89
- `),i.push(` connectorCreate${s}(input: ConnectorCreate${s}Input!): Connector${s}Payload!`)),r&&(c.push(`
90
- input ConnectorUpdate${s}Input {
209
+ `);
210
+ dynamicMutationLines.push(` connectorCreate${modelName}(input: ConnectorCreate${modelName}Input!): Connector${modelName}Payload!`);
211
+ }
212
+ if (hasUpdate) {
213
+ sdlParts.push(`
214
+ input ConnectorUpdate${modelName}Input {
91
215
  sourceID: String!
92
- input: JSON!
216
+ data: JSON!
217
+ }
218
+ `);
219
+ dynamicMutationLines.push(` connectorUpdate${modelName}(input: ConnectorUpdate${modelName}Input!): Connector${modelName}Payload!`);
220
+ }
221
+ }
222
+ }
223
+ }
224
+ if (dynamicMutationLines.length > 0) {
225
+ sdlParts.push(`extend type Mutation {\n${dynamicMutationLines.join('\n')}\n}`);
226
+ }
227
+ const sdl = sdlParts.join('\n');
228
+ // ---- Build resolvers ----
229
+ const queryFields = {
230
+ connector: (_source, args, context)=>{
231
+ const ctx = context;
232
+ return requireConnector(ctx).getState(args.name);
233
+ },
234
+ connectors: (_source, _args, context)=>{
235
+ const ctx = context;
236
+ return requireConnector(ctx).getStates();
237
+ },
238
+ connectorWriteGrants: (_source, _args, context)=>{
239
+ const ctx = context;
240
+ const conn = requireConnector(ctx);
241
+ if (conn.connectorWriteGrants == null) {
242
+ throw new Error('connector.connectorWriteGrants is not available in this context');
243
+ }
244
+ return conn.connectorWriteGrants();
245
+ }
246
+ };
247
+ const mutationFields = {
248
+ startConnectorAuth: (_source, args, context)=>{
249
+ const ctx = context;
250
+ return requireConnector(ctx).startAuth(args);
251
+ },
252
+ completeConnectorAuth: (_source, args, context)=>{
253
+ const ctx = context;
254
+ return requireConnector(ctx).completeAuth(args);
255
+ },
256
+ syncConnector: (_source, args, context)=>{
257
+ const ctx = context;
258
+ return requireConnector(ctx).triggerSync(args);
259
+ },
260
+ grantConnectorWriteCapability: (_source, args, context)=>{
261
+ const ctx = context;
262
+ const conn = requireConnector(ctx);
263
+ if (conn.grantWriteCapability == null) {
264
+ throw new Error('connector.grantWriteCapability is not available in this context');
265
+ }
266
+ return conn.grantWriteCapability(args);
267
+ },
268
+ revokeConnectorWriteCapability: (_source, args, context)=>{
269
+ const ctx = context;
270
+ const conn = requireConnector(ctx);
271
+ if (conn.revokeConnectorWriteCapability == null) {
272
+ throw new Error('connector.revokeConnectorWriteCapability is not available in this context');
273
+ }
274
+ return conn.revokeConnectorWriteCapability(args);
275
+ },
276
+ disconnectProvider: (_source, args, context)=>{
277
+ const ctx = context;
278
+ const conn = requireConnector(ctx);
279
+ if (conn.disconnectProvider == null) {
280
+ throw new Error('connector.disconnectProvider is not available in this context');
281
+ }
282
+ return conn.disconnectProvider(args);
283
+ }
284
+ };
285
+ // Dynamic per-model action resolvers
286
+ if (registry != null) {
287
+ for (const connector of registry.getAll()){
288
+ if (connector.actions == null || connector.actions.length === 0) {
289
+ continue;
290
+ }
291
+ const hasCreate = connector.actions.some((a)=>a.name === 'create');
292
+ const hasUpdate = connector.actions.some((a)=>a.name === 'update');
293
+ if (!hasCreate && !hasUpdate) {
294
+ continue;
295
+ }
296
+ const modelNames = getConnectorModelNames(connector).filter((name)=>name !== 'ExternalEntity');
297
+ for (const modelName of modelNames){
298
+ if (hasCreate) {
299
+ const connectorName = connector.name;
300
+ mutationFields[`connectorCreate${modelName}`] = async (_source, args, context, info)=>{
301
+ const ctx = context;
302
+ const conn = requireConnector(ctx);
303
+ if (conn.executeAction == null) {
304
+ throw new Error('connector.executeAction is not available in this context');
305
+ }
306
+ const input = args.input;
307
+ const writeDocument = ({ owner, modelID, unique, data })=>ctx.executeSetMutation({
308
+ modelID,
309
+ unique,
310
+ data,
311
+ owner,
312
+ info
313
+ }).then(()=>undefined);
314
+ return await conn.executeAction({
315
+ connector: connectorName,
316
+ action: 'create',
317
+ model: modelName,
318
+ input: input.data
319
+ }, writeDocument);
320
+ };
321
+ }
322
+ if (hasUpdate) {
323
+ const connectorName = connector.name;
324
+ mutationFields[`connectorUpdate${modelName}`] = async (_source, args, context, info)=>{
325
+ const ctx = context;
326
+ const conn = requireConnector(ctx);
327
+ if (conn.executeAction == null) {
328
+ throw new Error('connector.executeAction is not available in this context');
329
+ }
330
+ const input = args.input;
331
+ const writeDocument = ({ owner, modelID, unique, data })=>ctx.executeSetMutation({
332
+ modelID,
333
+ unique,
334
+ data,
335
+ owner,
336
+ info
337
+ }).then(()=>undefined);
338
+ return await conn.executeAction({
339
+ connector: connectorName,
340
+ action: 'update',
341
+ model: modelName,
342
+ input: input.data,
343
+ sourceID: input.sourceID
344
+ }, writeDocument);
345
+ };
346
+ }
347
+ }
348
+ }
349
+ }
350
+ const subscriptionFields = {
351
+ connectorSyncEvents: {
352
+ resolve: (event)=>event,
353
+ subscribe: (_source, args, context)=>{
354
+ const ctx = context;
355
+ return requireConnector(ctx).subscribeToSyncEvents(args.connector);
356
+ }
357
+ }
358
+ };
359
+ return {
360
+ sdl,
361
+ resolvers: {
362
+ queryFields,
363
+ mutationFields,
364
+ subscriptionFields,
365
+ typeFields: {
366
+ Connector: {
367
+ id: (source)=>{
368
+ const state = source;
369
+ return PluginNodeID.create('connector', 'Connector', state.name).toString();
370
+ }
371
+ }
372
+ },
373
+ nodeResolvers: {
374
+ Connector: {
375
+ resolve: (localID, context)=>{
376
+ const ctx = context;
377
+ return requireConnector(ctx).getState(localID);
378
+ }
379
+ }
380
+ }
381
+ }
382
+ };
93
383
  }
94
- `),i.push(` connectorUpdate${s}(input: ConnectorUpdate${s}Input!): Connector${s}Payload!`))}}i.length>0&&c.push(`extend type Mutation {
95
- ${i.join("\n")}
96
- }`);let s=c.join("\n"),l={startConnectorAuth:(t,n,o)=>e(o).startAuth(n),completeConnectorAuth:(t,n,o)=>e(o).completeAuth(n),syncConnector:(t,n,o)=>e(o).triggerSync(n)};if(null!=r)for(let t of r.getAll()){if(null==t.actions||0===t.actions.length)continue;let o=t.actions.some(t=>"create"===t.name),r=t.actions.some(t=>"update"===t.name);if(o||r)for(let c of n(t).filter(t=>"ExternalEntity"!==t)){if(o){let n=t.name;l[`connectorCreate${c}`]=async(t,o,r)=>{let i=e(r);if(null==i.executeAction)throw Error("connector.executeAction is not available in this context");let s=o.input;return await i.executeAction({connector:n,action:"create",model:c,input:s.input})}}if(r){let n=t.name;l[`connectorUpdate${c}`]=async(t,o,r)=>{let i=e(r);if(null==i.executeAction)throw Error("connector.executeAction is not available in this context");let s=o.input;return await i.executeAction({connector:n,action:"update",model:c,input:s.input,sourceID:s.sourceID})}}}}return{sdl:s,resolvers:{queryFields:{connector:(t,n,o)=>e(o).getState(n.name),connectors:(t,n,o)=>e(o).getStates()},mutationFields:l,subscriptionFields:{connectorSyncEvents:{resolve:t=>t,subscribe:(t,n,o)=>e(o).subscribeToSyncEvents(n.connector)}},typeFields:{Connector:{id:e=>t.create("connector","Connector",e.name).toString()}},nodeResolvers:{Connector:{resolve:(t,n)=>e(n).getState(t)}}}}}
@@ -1,11 +1,16 @@
1
1
  import type { DataProvider, SyncBoundary, SyncEvent, SyncStateStore } from '@kubun/connector';
2
2
  import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
3
4
  import type { ClustersRecord } from '@kubun/protocol';
5
+ import { type MutateDocuments } from './processor.js';
4
6
  export type SyncEngineParams = {
5
7
  stores: StoreProvider;
6
8
  stateStore: SyncStateStore;
7
9
  connectorName: string;
8
10
  clusters: ClustersRecord;
11
+ logger: Logger;
12
+ /** Engine-signed write helper routing imports through the mutation pipeline. */
13
+ mutateDocuments?: MutateDocuments;
9
14
  };
10
15
  export type RunSyncParams = {
11
16
  provider: DataProvider;
@@ -13,6 +18,7 @@ export type RunSyncParams = {
13
18
  boundary: SyncBoundary;
14
19
  full?: boolean;
15
20
  signal?: AbortSignal;
21
+ leaseMs: number;
16
22
  };
17
23
  export declare class SyncEngine {
18
24
  #private;
@@ -1 +1,163 @@
1
- import{EventEmitter as t}from"@enkaku/event";import{EntityProcessor as e}from"./processor.js";export class SyncEngine{#t;#e;#n;#s;#o;#c=new Map;#r=new t;constructor(t){for(let e of(this.#t=t.stores,this.#e=t.stateStore,this.#n=t.connectorName,this.#s=t.clusters,this.#o=new Map,Object.values(t.clusters)))for(let[t,n]of Object.entries(e.record))this.#o.set(t,e.models[n])}on(t,e){return this.#r.on(t,e)}#i(t,n){let s=this.#c.get(t);if(null!=s)return s;let o=this.#o.get(t);if(null==o)throw Error(`Unknown model ID "${t}" in connector "${this.#n}"`);let c=new e({stores:this.#t,modelID:t,ownerDID:n,connectorName:this.#n,edgeFields:o.fieldsMeta,clusters:this.#s});return this.#c.set(t,c),c}async run(t){let{provider:e,ownerDID:n,boundary:s,full:o,signal:c}=t,r=Date.now(),i=await this.#e.get(this.#n,n),a=!o&&i?.checkpoint!=null,l=a?"incremental":"initial";await this.#e.set(this.#n,n,{checkpoint:i?.checkpoint??null,lastSyncedAt:i?.lastSyncedAt??new Date().toISOString(),entityCount:i?.entityCount??0,status:"syncing"});let h=0,y=0,m=0,u=i?.checkpoint??null;try{let t={boundary:s,checkpoint:a?i?.checkpoint:void 0,signal:c};for await(let s of a?e.fetchChanges({...t,lastSyncedAt:i?.lastSyncedAt?new Date(i.lastSyncedAt):void 0}):e.fetchAll(t)){m++;let t=this.#i(s.modelID,n),e=await t.processBatch(s);h+=e.created+e.updated+e.deleted,y+=e.failed,null!=s.checkpoint&&(u=s.checkpoint);let o={type:"sync:progress",connectorName:this.#n,phase:l,entitiesProcessed:h,entitiesFailed:y,currentBatch:m,checkpoint:u};await this.#r.emit("sync",o),await this.#e.set(this.#n,n,{checkpoint:u,lastSyncedAt:new Date().toISOString(),entityCount:(i?.entityCount??0)+h,status:"syncing"})}let o=Date.now()-r;await this.#e.set(this.#n,n,{checkpoint:u,lastSyncedAt:new Date().toISOString(),entityCount:(i?.entityCount??0)+h,status:"idle"});let d={type:"sync:complete",connectorName:this.#n,totalProcessed:h,totalFailed:y,duration:o};await this.#r.emit("sync",d)}catch(s){let t=s instanceof Error?s.message:String(s);await this.#e.set(this.#n,n,{checkpoint:u,lastSyncedAt:i?.lastSyncedAt??new Date().toISOString(),entityCount:i?.entityCount??0,status:"error",error:t});let e={type:"sync:error",connectorName:this.#n,error:t};await this.#r.emit("sync",e)}}}
1
+ import { EventEmitter } from '@sozai/event';
2
+ import { EntityProcessor, MAX_LOGGED_ERRORS } from './processor.js';
3
+ export class SyncEngine {
4
+ #stores;
5
+ #stateStore;
6
+ #connectorName;
7
+ #clusters;
8
+ #logger;
9
+ #mutateDocuments;
10
+ #modelByID;
11
+ #processors = new Map();
12
+ #events = new EventEmitter();
13
+ constructor(params){
14
+ this.#stores = params.stores;
15
+ this.#stateStore = params.stateStore;
16
+ this.#connectorName = params.connectorName;
17
+ this.#clusters = params.clusters;
18
+ this.#logger = params.logger;
19
+ this.#mutateDocuments = params.mutateDocuments;
20
+ // Merge all cluster records into a single model lookup
21
+ this.#modelByID = new Map();
22
+ for (const cluster of Object.values(params.clusters)){
23
+ for (const [id, index] of Object.entries(cluster.record)){
24
+ this.#modelByID.set(id, cluster.models[index]);
25
+ }
26
+ }
27
+ }
28
+ on(event, callback) {
29
+ return this.#events.on(event, callback);
30
+ }
31
+ #getProcessor(modelID, ownerDID) {
32
+ const existing = this.#processors.get(modelID);
33
+ if (existing != null) return existing;
34
+ const model = this.#modelByID.get(modelID);
35
+ if (model == null) {
36
+ throw new Error(`Unknown model ID "${modelID}" in connector "${this.#connectorName}"`);
37
+ }
38
+ const mutateDocuments = this.#mutateDocuments;
39
+ if (mutateDocuments == null) {
40
+ throw new Error(`Connector "${this.#connectorName}" sync requires a signed-write helper to import documents`);
41
+ }
42
+ const processor = new EntityProcessor({
43
+ stores: this.#stores,
44
+ modelID,
45
+ ownerDID,
46
+ connectorName: this.#connectorName,
47
+ logger: this.#logger,
48
+ edgeFields: model.fieldsMeta,
49
+ clusters: this.#clusters,
50
+ writeDocument: (write)=>mutateDocuments({
51
+ owner: write.owner,
52
+ writes: [
53
+ {
54
+ type: 'set',
55
+ modelID: write.modelID,
56
+ unique: write.unique,
57
+ data: write.data
58
+ }
59
+ ]
60
+ }).then(()=>undefined)
61
+ });
62
+ this.#processors.set(modelID, processor);
63
+ return processor;
64
+ }
65
+ async run(params) {
66
+ const { provider, ownerDID, boundary, full, signal, leaseMs } = params;
67
+ const startTime = Date.now();
68
+ const existingState = await this.#stateStore.get(this.#connectorName, ownerDID);
69
+ const isIncremental = !full && existingState?.checkpoint != null;
70
+ const phase = isIncremental ? 'incremental' : 'initial';
71
+ // Mark as syncing
72
+ await this.#stateStore.set(this.#connectorName, ownerDID, {
73
+ checkpoint: existingState?.checkpoint ?? null,
74
+ lastSyncedAt: existingState?.lastSyncedAt ?? new Date().toISOString(),
75
+ entityCount: existingState?.entityCount ?? 0,
76
+ status: 'syncing'
77
+ });
78
+ let totalProcessed = 0;
79
+ let totalFailed = 0;
80
+ let batchNumber = 0;
81
+ let latestCheckpoint = existingState?.checkpoint ?? null;
82
+ const failedSample = [];
83
+ try {
84
+ const fetchParams = {
85
+ boundary,
86
+ checkpoint: isIncremental ? existingState?.checkpoint : undefined,
87
+ signal
88
+ };
89
+ const iterable = isIncremental ? provider.fetchChanges({
90
+ ...fetchParams,
91
+ lastSyncedAt: existingState?.lastSyncedAt ? new Date(existingState.lastSyncedAt) : undefined
92
+ }) : provider.fetchAll(fetchParams);
93
+ for await (const batch of iterable){
94
+ batchNumber++;
95
+ const processor = this.#getProcessor(batch.modelID, ownerDID);
96
+ const result = await processor.processBatch(batch);
97
+ totalProcessed += result.created + result.updated + result.deleted;
98
+ totalFailed += result.failed;
99
+ for (const error of result.errors){
100
+ if (failedSample.length >= MAX_LOGGED_ERRORS) break;
101
+ failedSample.push(error);
102
+ }
103
+ if (batch.checkpoint != null) {
104
+ latestCheckpoint = batch.checkpoint;
105
+ }
106
+ const progressEvent = {
107
+ type: 'sync:progress',
108
+ connectorName: this.#connectorName,
109
+ phase,
110
+ entitiesProcessed: totalProcessed,
111
+ entitiesFailed: totalFailed,
112
+ currentBatch: batchNumber,
113
+ checkpoint: latestCheckpoint
114
+ };
115
+ await this.#events.emit('sync', progressEvent);
116
+ await this.#stateStore.set(this.#connectorName, ownerDID, {
117
+ checkpoint: latestCheckpoint,
118
+ lastSyncedAt: new Date().toISOString(),
119
+ entityCount: (existingState?.entityCount ?? 0) + totalProcessed,
120
+ status: 'syncing'
121
+ });
122
+ // Heartbeat the lease so a long-running sync is not reclaimed mid-flight.
123
+ await this.#stateStore.renewSync(this.#connectorName, ownerDID, leaseMs);
124
+ }
125
+ const duration = Date.now() - startTime;
126
+ await this.#stateStore.set(this.#connectorName, ownerDID, {
127
+ checkpoint: latestCheckpoint,
128
+ lastSyncedAt: new Date().toISOString(),
129
+ entityCount: (existingState?.entityCount ?? 0) + totalProcessed,
130
+ status: 'idle'
131
+ });
132
+ const completeEvent = {
133
+ type: 'sync:complete',
134
+ connectorName: this.#connectorName,
135
+ totalProcessed,
136
+ totalFailed,
137
+ duration,
138
+ ...failedSample.length > 0 ? {
139
+ failedSample
140
+ } : {}
141
+ };
142
+ await this.#events.emit('sync', completeEvent);
143
+ } catch (err) {
144
+ const errorMessage = err instanceof Error ? err.message : String(err);
145
+ await this.#stateStore.set(this.#connectorName, ownerDID, {
146
+ checkpoint: latestCheckpoint,
147
+ lastSyncedAt: existingState?.lastSyncedAt ?? new Date().toISOString(),
148
+ entityCount: existingState?.entityCount ?? 0,
149
+ status: 'error',
150
+ error: errorMessage
151
+ });
152
+ const errorEvent = {
153
+ type: 'sync:error',
154
+ connectorName: this.#connectorName,
155
+ error: errorMessage,
156
+ ...failedSample.length > 0 ? {
157
+ failedSample
158
+ } : {}
159
+ };
160
+ await this.#events.emit('sync', errorEvent);
161
+ }
162
+ }
163
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ConnectorSyncEventPayload, CredentialProvider, SyncBoundary, SyncStateStore } from '@kubun/connector';
2
2
  import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
3
4
  import type { ConnectorRegistry } from '../registry.js';
5
+ import type { MutateDocuments } from './processor.js';
4
6
  /**
5
7
  * Minimal interface for the sync event emitter dependency.
6
8
  * ConnectorManager (not yet ported) satisfies this — when it is ported,
@@ -19,6 +21,10 @@ export type OrchestrateSyncParams = {
19
21
  ownerDID: string;
20
22
  stores: StoreProvider;
21
23
  boundary?: SyncBoundary;
24
+ leaseMs: number;
25
+ logger: Logger;
26
+ /** Engine-signed write helper routing imports through the mutation pipeline. */
27
+ mutateDocuments?: MutateDocuments;
22
28
  };
23
29
  export type OrchestrateSyncResult = {
24
30
  status: 'started' | 'already_syncing' | 'error';
@@ -1 +1,100 @@
1
- import{SyncEngine as e}from"./engine.js";export async function orchestrateSync(t){let{connectorName:r,full:n,registry:s,syncEventEmitter:a,stateStore:i,credentialProvider:o,ownerDID:c,stores:l}=t,u=s.get(r);if(null==u)return{status:"error"};let y=await i.get(r,c);if(y?.status==="syncing")return{status:"already_syncing"};let d="device"!==u.auth.provider?u.auth.provider:null,p=d?await o.get(d,c):null;if(null!=d&&null==p||null==u.serverProvider)return{status:"error"};let v=t.boundary??{maxAge:"P90D"},g=u.serverProvider({credential:p??{accessToken:"",scopes:[]},boundary:v}),m=new e({stores:l,stateStore:i,connectorName:r,clusters:u.clusters});return await a.emitSyncEvent({type:"started",connectorName:r}),m.on("sync",e=>{"sync:progress"===e.type?a.emitSyncEvent({type:"progress",connectorName:r,entitiesProcessed:e.entitiesProcessed,entitiesFailed:e.entitiesFailed}):"sync:complete"===e.type?a.emitSyncEvent({type:"completed",connectorName:r,totalProcessed:e.totalProcessed,totalFailed:e.totalFailed,duration:e.duration}):"sync:error"===e.type&&a.emitSyncEvent({type:"error",connectorName:r,error:e.error})}),m.run({provider:g,ownerDID:c,boundary:v,full:n}).catch(async e=>{await a.emitSyncEvent({type:"error",connectorName:r,error:e instanceof Error?e.message:String(e)})}),{status:"started"}}
1
+ import { SyncEngine } from './engine.js';
2
+ export async function orchestrateSync(params) {
3
+ const { connectorName, full, registry, syncEventEmitter, stateStore, credentialProvider, ownerDID, stores, leaseMs, logger } = params;
4
+ const connector = registry.get(connectorName);
5
+ if (connector == null) {
6
+ return {
7
+ status: 'error'
8
+ };
9
+ }
10
+ // Get credentials for OAuth connectors
11
+ const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
12
+ const credential = providerName ? await credentialProvider.get(providerName, ownerDID) : null;
13
+ if (providerName != null && credential == null) {
14
+ return {
15
+ status: 'error'
16
+ };
17
+ }
18
+ // Ensure server provider is available
19
+ if (connector.serverProvider == null) {
20
+ return {
21
+ status: 'error'
22
+ };
23
+ }
24
+ // Atomically claim the sync lease. This happens after the validation paths
25
+ // above so a failed claim never leaves a live lease with no running sync.
26
+ // A stale/expired lease (e.g. from a crashed run) is reclaimed automatically.
27
+ const claimed = await stateStore.claimSync(connectorName, ownerDID, leaseMs);
28
+ if (!claimed) {
29
+ return {
30
+ status: 'already_syncing'
31
+ };
32
+ }
33
+ const effectiveBoundary = params.boundary ?? {
34
+ maxAge: 'P90D'
35
+ };
36
+ const provider = connector.serverProvider({
37
+ credential: credential ?? {
38
+ accessToken: '',
39
+ scopes: []
40
+ },
41
+ boundary: effectiveBoundary
42
+ });
43
+ const engine = new SyncEngine({
44
+ stores,
45
+ stateStore,
46
+ connectorName,
47
+ clusters: connector.clusters,
48
+ logger,
49
+ mutateDocuments: params.mutateDocuments
50
+ });
51
+ // Emit started event
52
+ await syncEventEmitter.emitSyncEvent({
53
+ type: 'started',
54
+ connectorName
55
+ });
56
+ // Bridge engine events to sync event emitter
57
+ engine.on('sync', (event)=>{
58
+ if (event.type === 'sync:progress') {
59
+ syncEventEmitter.emitSyncEvent({
60
+ type: 'progress',
61
+ connectorName,
62
+ entitiesProcessed: event.entitiesProcessed,
63
+ entitiesFailed: event.entitiesFailed
64
+ });
65
+ } else if (event.type === 'sync:complete') {
66
+ syncEventEmitter.emitSyncEvent({
67
+ type: 'completed',
68
+ connectorName,
69
+ totalProcessed: event.totalProcessed,
70
+ totalFailed: event.totalFailed,
71
+ duration: event.duration,
72
+ failedSample: event.failedSample
73
+ });
74
+ } else if (event.type === 'sync:error') {
75
+ syncEventEmitter.emitSyncEvent({
76
+ type: 'error',
77
+ connectorName,
78
+ error: event.error,
79
+ failedSample: event.failedSample
80
+ });
81
+ }
82
+ });
83
+ // Run sync (fire-and-forget — mutation returns immediately)
84
+ engine.run({
85
+ provider,
86
+ ownerDID,
87
+ boundary: effectiveBoundary,
88
+ full,
89
+ leaseMs
90
+ }).catch(async (err)=>{
91
+ await syncEventEmitter.emitSyncEvent({
92
+ type: 'error',
93
+ connectorName,
94
+ error: err instanceof Error ? err.message : String(err)
95
+ });
96
+ });
97
+ return {
98
+ status: 'started'
99
+ };
100
+ }