@kubun/plugin-connector 0.14.0 → 0.14.2
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 +9 -0
- package/lib/action.js +9 -1
- package/lib/auth-required.d.ts +11 -0
- package/lib/auth-required.js +17 -0
- package/lib/credential-refresh-bridge.d.ts +11 -0
- package/lib/credential-refresh-bridge.js +27 -0
- package/lib/credential.d.ts +2 -2
- package/lib/credential.js +1 -1
- package/lib/index.d.ts +8 -2
- package/lib/index.js +293 -56
- package/lib/manager.d.ts +71 -3
- package/lib/manager.js +41 -6
- package/lib/oauth.d.ts +5 -1
- package/lib/oauth.js +5 -2
- package/lib/periodic-sync.d.ts +4 -0
- package/lib/periodic-sync.js +12 -0
- package/lib/schema.d.ts +45 -1
- package/lib/schema.js +122 -0
- package/lib/sync/orchestrate.d.ts +11 -1
- package/lib/sync/orchestrate.js +118 -76
- package/lib/sync/workflow.d.ts +11 -0
- package/lib/sync/workflow.js +26 -2
- package/lib/write-grants.d.ts +10 -2
- package/lib/write-grants.js +10 -1
- package/package.json +10 -10
package/lib/manager.js
CHANGED
|
@@ -3,12 +3,22 @@ export class ConnectorManager {
|
|
|
3
3
|
#registry;
|
|
4
4
|
#logger;
|
|
5
5
|
#activeGraphs = new Map();
|
|
6
|
-
#events
|
|
7
|
-
#syncEvents
|
|
6
|
+
#events;
|
|
7
|
+
#syncEvents;
|
|
8
|
+
#lifecycleEvents;
|
|
8
9
|
#defaults;
|
|
9
10
|
constructor(params){
|
|
10
11
|
this.#registry = params.registry;
|
|
11
12
|
this.#logger = params.logger;
|
|
13
|
+
this.#events = new EventEmitter({
|
|
14
|
+
logger: params.logger
|
|
15
|
+
});
|
|
16
|
+
this.#syncEvents = new EventEmitter({
|
|
17
|
+
logger: params.logger
|
|
18
|
+
});
|
|
19
|
+
this.#lifecycleEvents = new EventEmitter({
|
|
20
|
+
logger: params.logger
|
|
21
|
+
});
|
|
12
22
|
this.#defaults = {
|
|
13
23
|
boundary: params.defaults?.boundary ?? {
|
|
14
24
|
maxAge: 'P90D'
|
|
@@ -35,7 +45,7 @@ export class ConnectorManager {
|
|
|
35
45
|
name,
|
|
36
46
|
graphID
|
|
37
47
|
});
|
|
38
|
-
this.#events.
|
|
48
|
+
this.#events.fire('connector:activated', {
|
|
39
49
|
connectorName: name,
|
|
40
50
|
graphID
|
|
41
51
|
});
|
|
@@ -54,7 +64,7 @@ export class ConnectorManager {
|
|
|
54
64
|
this.#logger.info('Connector {name} deactivated', {
|
|
55
65
|
name
|
|
56
66
|
});
|
|
57
|
-
this.#events.
|
|
67
|
+
this.#events.fire('connector:deactivated', {
|
|
58
68
|
connectorName: name
|
|
59
69
|
});
|
|
60
70
|
}
|
|
@@ -77,11 +87,36 @@ export class ConnectorManager {
|
|
|
77
87
|
onConnectorDeactivated(callback) {
|
|
78
88
|
return this.#events.on('connector:deactivated', callback);
|
|
79
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Listen-only view of the typed event channel. Consumers subscribe here (e.g.
|
|
92
|
+
* `events.on('connector:authenticated', …)`); the emit side stays private to
|
|
93
|
+
* the manager, so the returned surface carries no `fire`/`emit`.
|
|
94
|
+
*/ get events() {
|
|
95
|
+
return this.#events;
|
|
96
|
+
}
|
|
97
|
+
/** Listen-only view of the merged lifecycle channel carrying every tagged event. */ get lifecycleEvents() {
|
|
98
|
+
return this.#lifecycleEvents;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Emit a lifecycle event to both the typed per-event channel and the merged
|
|
102
|
+
* channel. The two `fire` calls are independent and failure-isolated: a
|
|
103
|
+
* throwing listener on either channel is reported via the logger and cannot
|
|
104
|
+
* reject the caller or block the other channel.
|
|
105
|
+
*/ emitLifecycle(type, payload) {
|
|
106
|
+
this.#emitLifecycle(type, payload);
|
|
107
|
+
}
|
|
108
|
+
#emitLifecycle(type, payload) {
|
|
109
|
+
this.#events.fire(type, payload);
|
|
110
|
+
this.#lifecycleEvents.fire('lifecycle', {
|
|
111
|
+
type,
|
|
112
|
+
...payload
|
|
113
|
+
});
|
|
114
|
+
}
|
|
80
115
|
get syncEvents() {
|
|
81
116
|
return this.#syncEvents;
|
|
82
117
|
}
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
emitSyncEvent(event) {
|
|
119
|
+
this.#syncEvents.fire('sync', event);
|
|
85
120
|
}
|
|
86
121
|
onSyncEvent(callback) {
|
|
87
122
|
return this.#syncEvents.on('sync', callback);
|
package/lib/oauth.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type { Runtime } from '@sozai/runtime';
|
|
|
4
4
|
import type { ConnectorAPI, CredentialAPI } from './api.js';
|
|
5
5
|
import type { ConnectorRegistry } from './registry.js';
|
|
6
6
|
import type { CompleteConnectorAuthInput, CompleteConnectorAuthOutput, StartConnectorAuthInput, StartConnectorAuthOutput } from './schema.js';
|
|
7
|
+
export type CompleteAuthResult = {
|
|
8
|
+
output: CompleteConnectorAuthOutput;
|
|
9
|
+
ownerDID: string;
|
|
10
|
+
};
|
|
7
11
|
export type OAuthServiceParams = {
|
|
8
12
|
runtime: Runtime;
|
|
9
13
|
providers: Array<OAuthProviderDefinition>;
|
|
@@ -16,5 +20,5 @@ export declare class OAuthService {
|
|
|
16
20
|
#private;
|
|
17
21
|
constructor(params: OAuthServiceParams);
|
|
18
22
|
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI, preflightMethods?: MethodRegistry): Promise<StartConnectorAuthOutput>;
|
|
19
|
-
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<
|
|
23
|
+
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<CompleteAuthResult>;
|
|
20
24
|
}
|
package/lib/oauth.js
CHANGED
|
@@ -167,8 +167,11 @@ export class OAuthService {
|
|
|
167
167
|
controllerWrappableDID: pending.controllerWrappableDID ?? undefined
|
|
168
168
|
});
|
|
169
169
|
return {
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
output: {
|
|
171
|
+
providerName: args.provider,
|
|
172
|
+
scopes
|
|
173
|
+
},
|
|
174
|
+
ownerDID: pending.ownerDID
|
|
172
175
|
};
|
|
173
176
|
}
|
|
174
177
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
// The adaptive schedule and the manual-sync singleton share one subject so a
|
|
3
|
+
// scheduled run and a manual trigger interlock. Both fold the owner to its short
|
|
4
|
+
// form first: credential rows and sync-state rows are keyed on the normalized
|
|
5
|
+
// DID, and a viewer can arrive long-form (a mutation's `iss`).
|
|
6
|
+
export function periodicSyncSubject(connector, ownerDID) {
|
|
7
|
+
const owner = normalizeDID(ownerDID);
|
|
8
|
+
return {
|
|
9
|
+
owner,
|
|
10
|
+
subjectKey: `${connector}:${owner}`
|
|
11
|
+
};
|
|
12
|
+
}
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ConnectorSyncEventPayload } from '@kubun/connector';
|
|
2
2
|
import type { SchemaExtension } from '@kubun/engine';
|
|
3
3
|
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
4
|
-
import type { EventEmitter } from '@sozai/event';
|
|
4
|
+
import type { EventEmitter, EventsSource } from '@sozai/event';
|
|
5
|
+
import type { ConnectorLifecycleEventName, ConnectorLifecycleEventPayload } from './manager.js';
|
|
5
6
|
import type { ConnectorRegistry } from './registry.js';
|
|
6
7
|
import type { SignedDocumentWriter } from './sync/processor.js';
|
|
7
8
|
/**
|
|
@@ -19,6 +20,7 @@ export type ConnectorState = {
|
|
|
19
20
|
lastSyncedAt: string | null;
|
|
20
21
|
entityCount: number | null;
|
|
21
22
|
error: string | null;
|
|
23
|
+
workflowStatusError: 'WORKFLOW_UNAVAILABLE' | null;
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
26
|
* Input/output types for connector auth mutations.
|
|
@@ -139,6 +141,47 @@ export type ConnectorSyncEventEmitter = EventEmitter<{
|
|
|
139
141
|
* potentially never.
|
|
140
142
|
*/
|
|
141
143
|
export declare function subscribeToConnectorSyncEvents(emitter: ConnectorSyncEventEmitter, connector?: string | null): AsyncIterableIterator<ConnectorSyncEventResult>;
|
|
144
|
+
/**
|
|
145
|
+
* Merged lifecycle event payload for subscriptions (GraphQL result type). Flat
|
|
146
|
+
* and nullable: a given event carries only the fields its variant defines, and
|
|
147
|
+
* the rest resolve to null (mirrors `ConnectorSyncEventResult`).
|
|
148
|
+
*/
|
|
149
|
+
export type ConnectorLifecycleEventResult = {
|
|
150
|
+
type: ConnectorLifecycleEventName;
|
|
151
|
+
ownerDID: string;
|
|
152
|
+
provider: string | null;
|
|
153
|
+
connector: string | null;
|
|
154
|
+
connectors: Array<string> | null;
|
|
155
|
+
scopes: Array<string> | null;
|
|
156
|
+
expiresAt: number | null;
|
|
157
|
+
reason: string | null;
|
|
158
|
+
subjectKey: string | null;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Flatten a merged lifecycle payload into the GraphQL result shape, filling the
|
|
162
|
+
* fields absent from the variant with null.
|
|
163
|
+
*/
|
|
164
|
+
export declare function toConnectorLifecycleEventResult(event: ConnectorLifecycleEventPayload): ConnectorLifecycleEventResult;
|
|
165
|
+
/**
|
|
166
|
+
* Subscribe to the merged lifecycle channel as a single viewer, yielding GraphQL
|
|
167
|
+
* result shapes. Every event is filtered by `ownerDID` against the caller's
|
|
168
|
+
* already-normalized `viewerDID`, so one viewer never observes another's auth
|
|
169
|
+
* activity; an optional `connector` further restricts delivery, matching both
|
|
170
|
+
* the auth-family `connectors[]` membership and the periodicSync single
|
|
171
|
+
* `connector`. Built on the same manual async iterator as
|
|
172
|
+
* `subscribeToConnectorSyncEvents` so a consumer's `return()` removes the
|
|
173
|
+
* emitter listener immediately rather than leaking behind a parked read.
|
|
174
|
+
*
|
|
175
|
+
* The emitter is the manager's listen-only `EventsSource`; `fromEmitter` only
|
|
176
|
+
* reads its `.on`, but its parameter is typed to the concrete emitter class, so
|
|
177
|
+
* the source is narrowed to it here.
|
|
178
|
+
*/
|
|
179
|
+
export declare function subscribeToConnectorLifecycleEvents(emitter: EventsSource<{
|
|
180
|
+
lifecycle: ConnectorLifecycleEventPayload;
|
|
181
|
+
}>, opts: {
|
|
182
|
+
viewerDID: string;
|
|
183
|
+
connector?: string | null;
|
|
184
|
+
}): AsyncIterableIterator<ConnectorLifecycleEventResult>;
|
|
142
185
|
/**
|
|
143
186
|
* Per-request context methods for the Connector query and mutation fields.
|
|
144
187
|
* Provided by the context factory when registry + credentials are configured.
|
|
@@ -153,6 +196,7 @@ export type ConnectorQueryContext = {
|
|
|
153
196
|
completeAuth: (args: CompleteConnectorAuthInput) => Promise<CompleteConnectorAuthOutput>;
|
|
154
197
|
triggerSync: (args: SyncTriggerInput) => Promise<SyncTriggerOutput>;
|
|
155
198
|
subscribeToSyncEvents: (connector?: string | null) => AsyncIterable<ConnectorSyncEventResult>;
|
|
199
|
+
subscribeToLifecycleEvents: (connector?: string | null) => AsyncIterable<ConnectorLifecycleEventResult>;
|
|
156
200
|
executeAction?: (args: ConnectorActionInput, writeDocument: SignedDocumentWriter) => Promise<ConnectorActionResult>;
|
|
157
201
|
grantWriteCapability?: (args: {
|
|
158
202
|
connector: string;
|
package/lib/schema.js
CHANGED
|
@@ -58,6 +58,107 @@ import { fromEmitter } from '@sozai/generator';
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Flatten a merged lifecycle payload into the GraphQL result shape, filling the
|
|
63
|
+
* fields absent from the variant with null.
|
|
64
|
+
*/ export function toConnectorLifecycleEventResult(event) {
|
|
65
|
+
const result = {
|
|
66
|
+
type: event.type,
|
|
67
|
+
ownerDID: event.ownerDID,
|
|
68
|
+
provider: null,
|
|
69
|
+
connector: null,
|
|
70
|
+
connectors: null,
|
|
71
|
+
scopes: null,
|
|
72
|
+
expiresAt: null,
|
|
73
|
+
reason: null,
|
|
74
|
+
subjectKey: null
|
|
75
|
+
};
|
|
76
|
+
switch(event.type){
|
|
77
|
+
case 'connector:authenticated':
|
|
78
|
+
result.provider = event.provider;
|
|
79
|
+
result.connectors = event.connectors;
|
|
80
|
+
result.scopes = event.scopes;
|
|
81
|
+
break;
|
|
82
|
+
case 'connector:deauthenticated':
|
|
83
|
+
result.provider = event.provider;
|
|
84
|
+
result.connectors = event.connectors;
|
|
85
|
+
break;
|
|
86
|
+
case 'connector:credentialRefreshed':
|
|
87
|
+
result.provider = event.provider;
|
|
88
|
+
result.connectors = event.connectors;
|
|
89
|
+
result.scopes = event.scopes;
|
|
90
|
+
result.expiresAt = event.expiresAt ?? null;
|
|
91
|
+
break;
|
|
92
|
+
case 'connector:authRequired':
|
|
93
|
+
result.provider = event.provider;
|
|
94
|
+
result.connectors = event.connectors;
|
|
95
|
+
result.reason = event.reason;
|
|
96
|
+
break;
|
|
97
|
+
case 'periodicSync:enabled':
|
|
98
|
+
case 'periodicSync:disabled':
|
|
99
|
+
result.connector = event.connector;
|
|
100
|
+
result.subjectKey = event.subjectKey;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Subscribe to the merged lifecycle channel as a single viewer, yielding GraphQL
|
|
107
|
+
* result shapes. Every event is filtered by `ownerDID` against the caller's
|
|
108
|
+
* already-normalized `viewerDID`, so one viewer never observes another's auth
|
|
109
|
+
* activity; an optional `connector` further restricts delivery, matching both
|
|
110
|
+
* the auth-family `connectors[]` membership and the periodicSync single
|
|
111
|
+
* `connector`. Built on the same manual async iterator as
|
|
112
|
+
* `subscribeToConnectorSyncEvents` so a consumer's `return()` removes the
|
|
113
|
+
* emitter listener immediately rather than leaking behind a parked read.
|
|
114
|
+
*
|
|
115
|
+
* The emitter is the manager's listen-only `EventsSource`; `fromEmitter` only
|
|
116
|
+
* reads its `.on`, but its parameter is typed to the concrete emitter class, so
|
|
117
|
+
* the source is narrowed to it here.
|
|
118
|
+
*/ export function subscribeToConnectorLifecycleEvents(emitter, opts) {
|
|
119
|
+
const { viewerDID, connector } = opts;
|
|
120
|
+
const filter = (event)=>{
|
|
121
|
+
if (event.ownerDID !== viewerDID) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (connector == null) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return 'connectors' in event ? event.connectors.includes(connector) : event.connector === connector;
|
|
128
|
+
};
|
|
129
|
+
const generator = fromEmitter(emitter, 'lifecycle', {
|
|
130
|
+
filter
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
[Symbol.asyncIterator] () {
|
|
134
|
+
return this;
|
|
135
|
+
},
|
|
136
|
+
async next () {
|
|
137
|
+
const { done, value } = await generator.next();
|
|
138
|
+
return done ? {
|
|
139
|
+
done: true,
|
|
140
|
+
value: undefined
|
|
141
|
+
} : {
|
|
142
|
+
done: false,
|
|
143
|
+
value: toConnectorLifecycleEventResult(value)
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
async return () {
|
|
147
|
+
await generator.return();
|
|
148
|
+
return {
|
|
149
|
+
done: true,
|
|
150
|
+
value: undefined
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
async throw (reason) {
|
|
154
|
+
await generator.throw(reason);
|
|
155
|
+
return {
|
|
156
|
+
done: true,
|
|
157
|
+
value: undefined
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
61
162
|
function requireConnector(ctx) {
|
|
62
163
|
if (ctx.connector == null) {
|
|
63
164
|
throw new Error('connector plugin is not wired into this context');
|
|
@@ -116,6 +217,7 @@ type Connector implements Node {
|
|
|
116
217
|
lastSyncedAt: String
|
|
117
218
|
entityCount: Int
|
|
118
219
|
error: String
|
|
220
|
+
workflowStatusError: String
|
|
119
221
|
}
|
|
120
222
|
|
|
121
223
|
type StartConnectorAuthResult {
|
|
@@ -144,6 +246,18 @@ type ConnectorSyncEvent {
|
|
|
144
246
|
error: String
|
|
145
247
|
}
|
|
146
248
|
|
|
249
|
+
type ConnectorLifecycleEvent {
|
|
250
|
+
type: String!
|
|
251
|
+
ownerDID: String!
|
|
252
|
+
provider: String
|
|
253
|
+
connector: String
|
|
254
|
+
connectors: [String!]
|
|
255
|
+
scopes: [String!]
|
|
256
|
+
expiresAt: Float
|
|
257
|
+
reason: String
|
|
258
|
+
subjectKey: String
|
|
259
|
+
}
|
|
260
|
+
|
|
147
261
|
type ConnectorActionError {
|
|
148
262
|
code: String!
|
|
149
263
|
message: String!
|
|
@@ -175,6 +289,7 @@ extend type Mutation {
|
|
|
175
289
|
|
|
176
290
|
extend type Subscription {
|
|
177
291
|
connectorSyncEvents(connector: String): ConnectorSyncEvent!
|
|
292
|
+
connectorLifecycleEvents(connector: String): ConnectorLifecycleEvent!
|
|
178
293
|
}
|
|
179
294
|
`);
|
|
180
295
|
// Recurring-sync control. Gated because it references `PeriodicSync`, which is
|
|
@@ -398,6 +513,13 @@ input ConnectorUpdate${modelName}Input {
|
|
|
398
513
|
const ctx = context;
|
|
399
514
|
return requireConnector(ctx).subscribeToSyncEvents(args.connector);
|
|
400
515
|
}
|
|
516
|
+
},
|
|
517
|
+
connectorLifecycleEvents: {
|
|
518
|
+
resolve: (event)=>event,
|
|
519
|
+
subscribe: (_source, args, context)=>{
|
|
520
|
+
const ctx = context;
|
|
521
|
+
return requireConnector(ctx).subscribeToLifecycleEvents(args.connector);
|
|
522
|
+
}
|
|
401
523
|
}
|
|
402
524
|
};
|
|
403
525
|
return {
|
|
@@ -10,7 +10,7 @@ import type { MutateDocuments } from './processor.js';
|
|
|
10
10
|
* this type can be replaced with the concrete class.
|
|
11
11
|
*/
|
|
12
12
|
export type SyncEventEmitter = {
|
|
13
|
-
emitSyncEvent(event: ConnectorSyncEventPayload):
|
|
13
|
+
emitSyncEvent(event: ConnectorSyncEventPayload): void;
|
|
14
14
|
};
|
|
15
15
|
export type OrchestrateSyncParams = {
|
|
16
16
|
connectorName: string;
|
|
@@ -33,6 +33,16 @@ export type OrchestrateSyncParams = {
|
|
|
33
33
|
* imports upsert by unique key).
|
|
34
34
|
*/
|
|
35
35
|
inFlight?: Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Signal that this sync bailed because the provider credential is absent, so a
|
|
38
|
+
* consumer can prompt (re)authorization. Additive to the sync `error` event —
|
|
39
|
+
* not a replacement. Fired only for a missing credential, never for an absent
|
|
40
|
+
* server provider.
|
|
41
|
+
*/
|
|
42
|
+
emitAuthRequired?: (params: {
|
|
43
|
+
provider: string;
|
|
44
|
+
ownerDID: string;
|
|
45
|
+
}) => void;
|
|
36
46
|
};
|
|
37
47
|
export type OrchestrateSyncResult = {
|
|
38
48
|
status: 'started' | 'already_syncing' | 'error';
|
package/lib/sync/orchestrate.js
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
import { SyncEngine } from './engine.js';
|
|
2
2
|
export async function orchestrateSync(params) {
|
|
3
3
|
const { connectorName, full, registry, syncEventEmitter, stateStore, credentialProvider, ownerDID, stores, inFlight, logger } = params;
|
|
4
|
+
// A failed emission must never escape as an unhandled rejection. The event
|
|
5
|
+
// emitter rejects `emit()` when any subscriber throws, and these emits are
|
|
6
|
+
// either awaited on a bail path (where a throw would divert into the outer
|
|
7
|
+
// catch and deliver a second, misleading error) or floated on the
|
|
8
|
+
// fire-and-forget run chain (where a throw would crash a strict host). Swallow
|
|
9
|
+
// and log, so a misbehaving subscriber cannot corrupt the sync's own signalling.
|
|
10
|
+
const safeEmit = async (event)=>{
|
|
11
|
+
try {
|
|
12
|
+
await syncEventEmitter.emitSyncEvent(event);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
logger.warn('connector sync event delivery failed {connectorName}', {
|
|
15
|
+
connectorName,
|
|
16
|
+
error: err instanceof Error ? err.message : String(err)
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// A failed sync must be observable. Every bail below emits an `error` event so a
|
|
21
|
+
// subscriber sees the failure instead of silence — a swallowed failure here
|
|
22
|
+
// starves concurrent connectors that never learn their sync was dropped.
|
|
23
|
+
const emitError = (error)=>safeEmit({
|
|
24
|
+
type: 'error',
|
|
25
|
+
connectorName,
|
|
26
|
+
error
|
|
27
|
+
});
|
|
4
28
|
const connector = registry.get(connectorName);
|
|
5
29
|
if (connector == null) {
|
|
30
|
+
await emitError('connector is not registered');
|
|
6
31
|
return {
|
|
7
32
|
status: 'error'
|
|
8
33
|
};
|
|
9
34
|
}
|
|
10
35
|
// Reserve the (connector, owner) synchronously — before any await — so two
|
|
11
36
|
// concurrent triggers cannot both pass the check. Released once the run
|
|
12
|
-
// settles (or
|
|
37
|
+
// settles (or on any early bail).
|
|
13
38
|
const guardKey = `${connectorName}:${ownerDID}`;
|
|
14
39
|
if (inFlight != null) {
|
|
15
40
|
if (inFlight.has(guardKey)) {
|
|
@@ -20,86 +45,103 @@ export async function orchestrateSync(params) {
|
|
|
20
45
|
inFlight.add(guardKey);
|
|
21
46
|
}
|
|
22
47
|
const release = ()=>inFlight?.delete(guardKey);
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
// Everything from the reservation to the engine handoff runs under one guard:
|
|
49
|
+
// any throw here (credential fetch, provider construction, the started emit)
|
|
50
|
+
// must release the reservation, or the guard would wedge every later sync into
|
|
51
|
+
// a silent `already_syncing`. The engine promise's own `.finally(release)` owns
|
|
52
|
+
// the guard only once `run()` has been handed off.
|
|
53
|
+
try {
|
|
54
|
+
// Get credentials for OAuth connectors
|
|
55
|
+
const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
|
|
56
|
+
const credential = providerName ? await credentialProvider.get(providerName, ownerDID) : null;
|
|
57
|
+
if (providerName != null && credential == null) {
|
|
58
|
+
release();
|
|
59
|
+
// Additive to the error event below: the sync stream still sees the failure,
|
|
60
|
+
// and a lifecycle consumer additionally learns (re)authorization is needed.
|
|
61
|
+
params.emitAuthRequired?.({
|
|
62
|
+
provider: providerName,
|
|
63
|
+
ownerDID
|
|
64
|
+
});
|
|
65
|
+
await emitError(`no credential for provider "${providerName}"`);
|
|
66
|
+
return {
|
|
67
|
+
status: 'error'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Ensure server provider is available
|
|
71
|
+
if (connector.serverProvider == null) {
|
|
72
|
+
release();
|
|
73
|
+
await emitError('connector has no server provider');
|
|
74
|
+
return {
|
|
75
|
+
status: 'error'
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const effectiveBoundary = params.boundary ?? {
|
|
79
|
+
maxAge: 'P90D'
|
|
80
|
+
};
|
|
81
|
+
const provider = connector.serverProvider({
|
|
82
|
+
credential: credential ?? {
|
|
83
|
+
accessToken: '',
|
|
84
|
+
scopes: []
|
|
85
|
+
},
|
|
86
|
+
boundary: effectiveBoundary
|
|
87
|
+
});
|
|
88
|
+
const engine = new SyncEngine({
|
|
89
|
+
stores,
|
|
90
|
+
stateStore,
|
|
91
|
+
connectorName,
|
|
92
|
+
clusters: connector.clusters,
|
|
93
|
+
logger,
|
|
94
|
+
mutateDocuments: params.mutateDocuments
|
|
95
|
+
});
|
|
96
|
+
// Emit started event
|
|
97
|
+
await safeEmit({
|
|
98
|
+
type: 'started',
|
|
99
|
+
connectorName
|
|
100
|
+
});
|
|
101
|
+
// Bridge engine events to sync event emitter
|
|
102
|
+
engine.on('sync', (event)=>{
|
|
103
|
+
if (event.type === 'sync:progress') {
|
|
104
|
+
void safeEmit({
|
|
105
|
+
type: 'progress',
|
|
106
|
+
connectorName,
|
|
107
|
+
entitiesProcessed: event.entitiesProcessed,
|
|
108
|
+
entitiesFailed: event.entitiesFailed
|
|
109
|
+
});
|
|
110
|
+
} else if (event.type === 'sync:complete') {
|
|
111
|
+
void safeEmit({
|
|
112
|
+
type: 'completed',
|
|
113
|
+
connectorName,
|
|
114
|
+
totalProcessed: event.totalProcessed,
|
|
115
|
+
totalFailed: event.totalFailed,
|
|
116
|
+
duration: event.duration,
|
|
117
|
+
failedSample: event.failedSample
|
|
118
|
+
});
|
|
119
|
+
} else if (event.type === 'sync:error') {
|
|
120
|
+
void safeEmit({
|
|
121
|
+
type: 'error',
|
|
122
|
+
connectorName,
|
|
123
|
+
error: event.error,
|
|
124
|
+
failedSample: event.failedSample
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// Run sync (fire-and-forget — mutation returns immediately)
|
|
129
|
+
engine.run({
|
|
130
|
+
provider,
|
|
131
|
+
ownerDID,
|
|
132
|
+
boundary: effectiveBoundary,
|
|
133
|
+
full
|
|
134
|
+
}).catch((err)=>emitError(err instanceof Error ? err.message : String(err))).finally(release);
|
|
28
135
|
return {
|
|
29
|
-
status: '
|
|
136
|
+
status: 'started'
|
|
30
137
|
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// A pre-handoff throw: the engine never took ownership of the guard, so
|
|
140
|
+
// release it here and surface the failure.
|
|
34
141
|
release();
|
|
142
|
+
await emitError(err instanceof Error ? err.message : String(err));
|
|
35
143
|
return {
|
|
36
144
|
status: 'error'
|
|
37
145
|
};
|
|
38
146
|
}
|
|
39
|
-
const effectiveBoundary = params.boundary ?? {
|
|
40
|
-
maxAge: 'P90D'
|
|
41
|
-
};
|
|
42
|
-
const provider = connector.serverProvider({
|
|
43
|
-
credential: credential ?? {
|
|
44
|
-
accessToken: '',
|
|
45
|
-
scopes: []
|
|
46
|
-
},
|
|
47
|
-
boundary: effectiveBoundary
|
|
48
|
-
});
|
|
49
|
-
const engine = new SyncEngine({
|
|
50
|
-
stores,
|
|
51
|
-
stateStore,
|
|
52
|
-
connectorName,
|
|
53
|
-
clusters: connector.clusters,
|
|
54
|
-
logger,
|
|
55
|
-
mutateDocuments: params.mutateDocuments
|
|
56
|
-
});
|
|
57
|
-
// Emit started event
|
|
58
|
-
await syncEventEmitter.emitSyncEvent({
|
|
59
|
-
type: 'started',
|
|
60
|
-
connectorName
|
|
61
|
-
});
|
|
62
|
-
// Bridge engine events to sync event emitter
|
|
63
|
-
engine.on('sync', (event)=>{
|
|
64
|
-
if (event.type === 'sync:progress') {
|
|
65
|
-
syncEventEmitter.emitSyncEvent({
|
|
66
|
-
type: 'progress',
|
|
67
|
-
connectorName,
|
|
68
|
-
entitiesProcessed: event.entitiesProcessed,
|
|
69
|
-
entitiesFailed: event.entitiesFailed
|
|
70
|
-
});
|
|
71
|
-
} else if (event.type === 'sync:complete') {
|
|
72
|
-
syncEventEmitter.emitSyncEvent({
|
|
73
|
-
type: 'completed',
|
|
74
|
-
connectorName,
|
|
75
|
-
totalProcessed: event.totalProcessed,
|
|
76
|
-
totalFailed: event.totalFailed,
|
|
77
|
-
duration: event.duration,
|
|
78
|
-
failedSample: event.failedSample
|
|
79
|
-
});
|
|
80
|
-
} else if (event.type === 'sync:error') {
|
|
81
|
-
syncEventEmitter.emitSyncEvent({
|
|
82
|
-
type: 'error',
|
|
83
|
-
connectorName,
|
|
84
|
-
error: event.error,
|
|
85
|
-
failedSample: event.failedSample
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
// Run sync (fire-and-forget — mutation returns immediately)
|
|
90
|
-
engine.run({
|
|
91
|
-
provider,
|
|
92
|
-
ownerDID,
|
|
93
|
-
boundary: effectiveBoundary,
|
|
94
|
-
full
|
|
95
|
-
}).catch(async (err)=>{
|
|
96
|
-
await syncEventEmitter.emitSyncEvent({
|
|
97
|
-
type: 'error',
|
|
98
|
-
connectorName,
|
|
99
|
-
error: err instanceof Error ? err.message : String(err)
|
|
100
|
-
});
|
|
101
|
-
}).finally(release);
|
|
102
|
-
return {
|
|
103
|
-
status: 'started'
|
|
104
|
-
};
|
|
105
147
|
}
|
package/lib/sync/workflow.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type ConnectorSyncWorkflowDefinition = {
|
|
|
33
33
|
name: string;
|
|
34
34
|
};
|
|
35
35
|
handlers: Record<string, ConnectorSyncHandler>;
|
|
36
|
+
managed: true;
|
|
36
37
|
};
|
|
37
38
|
export type ConnectorSyncWorkflowParams = {
|
|
38
39
|
registry: ConnectorRegistry;
|
|
@@ -43,6 +44,16 @@ export type ConnectorSyncWorkflowParams = {
|
|
|
43
44
|
mutateDocuments?: MutateDocuments;
|
|
44
45
|
boundary?: SyncBoundary;
|
|
45
46
|
logger: Logger;
|
|
47
|
+
/**
|
|
48
|
+
* Signal that a sync cannot proceed because the provider credential is absent,
|
|
49
|
+
* so a consumer can prompt (re)authorization. Fired only for a genuinely
|
|
50
|
+
* missing credential — never for an absent server provider, which is an
|
|
51
|
+
* ordinary offline/configuration outcome.
|
|
52
|
+
*/
|
|
53
|
+
emitAuthRequired?: (params: {
|
|
54
|
+
provider: string;
|
|
55
|
+
ownerDID: string;
|
|
56
|
+
}) => void;
|
|
46
57
|
};
|
|
47
58
|
/**
|
|
48
59
|
* Build the `connector-sync` workflow definition. One sync pass is a self-looping
|