@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/manager.js CHANGED
@@ -3,12 +3,22 @@ export class ConnectorManager {
3
3
  #registry;
4
4
  #logger;
5
5
  #activeGraphs = new Map();
6
- #events = new EventEmitter();
7
- #syncEvents = new EventEmitter();
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.emit('connector:activated', {
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.emit('connector:deactivated', {
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
- async emitSyncEvent(event) {
84
- await this.#syncEvents.emit('sync', event);
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<CompleteConnectorAuthOutput>;
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
- providerName: args.provider,
171
- scopes
170
+ output: {
171
+ providerName: args.provider,
172
+ scopes
173
+ },
174
+ ownerDID: pending.ownerDID
172
175
  };
173
176
  }
174
177
  }
@@ -0,0 +1,4 @@
1
+ export declare function periodicSyncSubject(connector: string, ownerDID: string): {
2
+ owner: string;
3
+ subjectKey: string;
4
+ };
@@ -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): Promise<void>;
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';
@@ -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 here on any early bail).
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
- // Get credentials for OAuth connectors
24
- const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
25
- const credential = providerName ? await credentialProvider.get(providerName, ownerDID) : null;
26
- if (providerName != null && credential == null) {
27
- release();
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: 'error'
136
+ status: 'started'
30
137
  };
31
- }
32
- // Ensure server provider is available
33
- if (connector.serverProvider == null) {
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
  }
@@ -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