@kubun/plugin-connector 0.10.0 → 0.12.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/action.d.ts +10 -0
- package/lib/action.js +106 -1
- package/lib/api.d.ts +9 -0
- package/lib/api.js +108 -1
- package/lib/boundary.js +73 -1
- package/lib/credential.d.ts +1 -1
- package/lib/credential.js +107 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.js +223 -1
- package/lib/manager.d.ts +1 -1
- package/lib/manager.js +90 -1
- package/lib/oauth.d.ts +5 -3
- package/lib/oauth.js +137 -1
- package/lib/registry.js +24 -1
- package/lib/schema.d.ts +44 -2
- package/lib/schema.js +298 -11
- package/lib/sync/engine.d.ts +6 -0
- package/lib/sync/engine.js +163 -1
- package/lib/sync/orchestrate.d.ts +6 -0
- package/lib/sync/orchestrate.js +100 -1
- package/lib/sync/processor.d.ts +34 -2
- package/lib/sync/processor.js +191 -1
- package/lib/sync/state.d.ts +2 -0
- package/lib/sync/state.js +57 -1
- package/lib/write-grants.d.ts +35 -0
- package/lib/write-grants.js +287 -0
- package/package.json +32 -26
package/lib/schema.js
CHANGED
|
@@ -1,4 +1,90 @@
|
|
|
1
|
-
import{PluginNodeID
|
|
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
|
-
`);
|
|
80
|
-
|
|
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
|
-
`)
|
|
86
|
-
|
|
202
|
+
`);
|
|
203
|
+
}
|
|
204
|
+
if (hasCreate) {
|
|
205
|
+
sdlParts.push(`
|
|
206
|
+
input ConnectorCreate${modelName}Input {
|
|
87
207
|
data: JSON!
|
|
88
208
|
}
|
|
89
|
-
`)
|
|
90
|
-
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
216
|
data: JSON!
|
|
93
217
|
}
|
|
94
|
-
`)
|
|
95
|
-
${
|
|
96
|
-
}
|
|
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
|
+
};
|
|
383
|
+
}
|
package/lib/sync/engine.d.ts
CHANGED
|
@@ -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;
|
package/lib/sync/engine.js
CHANGED
|
@@ -1 +1,163 @@
|
|
|
1
|
-
import{EventEmitter
|
|
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';
|
package/lib/sync/orchestrate.js
CHANGED
|
@@ -1 +1,100 @@
|
|
|
1
|
-
import{SyncEngine
|
|
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
|
+
}
|