@kubun/plugin-connector 0.13.1 → 0.14.1

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.
@@ -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,97 @@ 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
+ await emitError(`no credential for provider "${providerName}"`);
60
+ return {
61
+ status: 'error'
62
+ };
63
+ }
64
+ // Ensure server provider is available
65
+ if (connector.serverProvider == null) {
66
+ release();
67
+ await emitError('connector has no server provider');
68
+ return {
69
+ status: 'error'
70
+ };
71
+ }
72
+ const effectiveBoundary = params.boundary ?? {
73
+ maxAge: 'P90D'
74
+ };
75
+ const provider = connector.serverProvider({
76
+ credential: credential ?? {
77
+ accessToken: '',
78
+ scopes: []
79
+ },
80
+ boundary: effectiveBoundary
81
+ });
82
+ const engine = new SyncEngine({
83
+ stores,
84
+ stateStore,
85
+ connectorName,
86
+ clusters: connector.clusters,
87
+ logger,
88
+ mutateDocuments: params.mutateDocuments
89
+ });
90
+ // Emit started event
91
+ await safeEmit({
92
+ type: 'started',
93
+ connectorName
94
+ });
95
+ // Bridge engine events to sync event emitter
96
+ engine.on('sync', (event)=>{
97
+ if (event.type === 'sync:progress') {
98
+ void safeEmit({
99
+ type: 'progress',
100
+ connectorName,
101
+ entitiesProcessed: event.entitiesProcessed,
102
+ entitiesFailed: event.entitiesFailed
103
+ });
104
+ } else if (event.type === 'sync:complete') {
105
+ void safeEmit({
106
+ type: 'completed',
107
+ connectorName,
108
+ totalProcessed: event.totalProcessed,
109
+ totalFailed: event.totalFailed,
110
+ duration: event.duration,
111
+ failedSample: event.failedSample
112
+ });
113
+ } else if (event.type === 'sync:error') {
114
+ void safeEmit({
115
+ type: 'error',
116
+ connectorName,
117
+ error: event.error,
118
+ failedSample: event.failedSample
119
+ });
120
+ }
121
+ });
122
+ // Run sync (fire-and-forget — mutation returns immediately)
123
+ engine.run({
124
+ provider,
125
+ ownerDID,
126
+ boundary: effectiveBoundary,
127
+ full
128
+ }).catch((err)=>emitError(err instanceof Error ? err.message : String(err))).finally(release);
28
129
  return {
29
- status: 'error'
130
+ status: 'started'
30
131
  };
31
- }
32
- // Ensure server provider is available
33
- if (connector.serverProvider == null) {
132
+ } catch (err) {
133
+ // A pre-handoff throw: the engine never took ownership of the guard, so
134
+ // release it here and surface the failure.
34
135
  release();
136
+ await emitError(err instanceof Error ? err.message : String(err));
35
137
  return {
36
138
  status: 'error'
37
139
  };
38
140
  }
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
141
  }
@@ -1,6 +1,8 @@
1
- import type { CredentialProvider, SyncBoundary, SyncStateStore } from '@kubun/connector';
1
+ import type { SyncBoundary, SyncStateStore } from '@kubun/connector';
2
+ import type { CredentialProvider } from '@kubun/credential-types';
2
3
  import type { StoreProvider } from '@kubun/db';
3
4
  import type { Logger } from '@kubun/logger';
5
+ import type { WorkflowOutcome } from '@kubun/plugin-workflow-api';
4
6
  import type { ConnectorRegistry } from '../registry.js';
5
7
  import type { SyncEventEmitter } from './orchestrate.js';
6
8
  import { type MutateDocuments } from './processor.js';
@@ -22,6 +24,7 @@ type HandlerResult = {
22
24
  } | {
23
25
  status: 'end';
24
26
  state: Record<string, unknown>;
27
+ outcome?: WorkflowOutcome;
25
28
  };
26
29
  type ConnectorSyncHandler = (ctx: HandlerContext) => Promise<HandlerResult>;
27
30
  export type ConnectorSyncWorkflowDefinition = {
@@ -45,11 +45,30 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
45
45
  }
46
46
  const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
47
47
  const credential = providerName ? await credentialProvider.get(providerName, ownerDID) : null;
48
- if (providerName != null && credential == null) {
49
- throw new Error(`Missing credential for connector "${connectorName}"`);
50
- }
51
- if (connector.serverProvider == null) {
52
- throw new Error(`Connector "${connectorName}" has no server provider`);
48
+ // A missing credential or absent server provider is "can't sync right now",
49
+ // not a failure: end offline (leaving prior sync state untouched) so the
50
+ // adaptive scheduler backs off on the offline branch rather than the error
51
+ // one. A genuine mid-sync throw below still terminates failed (⇒ error).
52
+ if (providerName != null && credential == null || connector.serverProvider == null) {
53
+ const offlineState = {
54
+ connectorName,
55
+ ownerDID,
56
+ full,
57
+ phase: 'initial',
58
+ checkpoint: null,
59
+ baseEntityCount: 0,
60
+ totalProcessed: 0,
61
+ totalFailed: 0,
62
+ batchNumber: 0,
63
+ startTime: Date.now(),
64
+ lastSyncedAt: new Date().toISOString(),
65
+ failedSample: []
66
+ };
67
+ return {
68
+ status: 'end',
69
+ state: offlineState,
70
+ outcome: 'offline'
71
+ };
53
72
  }
54
73
  const existing = await stateStore.get(connectorName, ownerDID);
55
74
  const isIncremental = !full && existing?.checkpoint != null;
@@ -216,9 +235,13 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
216
235
  failedSample: state.failedSample
217
236
  } : {}
218
237
  });
238
+ // Productive iff this run applied at least one entity; the per-run counter
239
+ // starts at 0 in `start` and accrues in `batch`.
240
+ const outcome = state.totalProcessed > 0 ? 'changed' : 'idle';
219
241
  return {
220
242
  status: 'end',
221
- state
243
+ state,
244
+ outcome
222
245
  };
223
246
  } catch (err) {
224
247
  await reportError(state.connectorName, state.ownerDID, state.checkpoint, state.lastSyncedAt, state.baseEntityCount + state.totalProcessed, err);
@@ -1,4 +1,4 @@
1
- import type { CredentialProvider } from '@kubun/connector';
1
+ import type { CredentialProvider } from '@kubun/credential-types';
2
2
  import { HLC } from '@kubun/hlc';
3
3
  import { getDelegationStore } from '@kubun/store-delegation';
4
4
  import type { ConnectorRegistry } from './registry.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-connector",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -17,7 +17,24 @@
17
17
  "@kokuin/capability": "^0.3.0",
18
18
  "@kokuin/jwe": "^0.1.0",
19
19
  "@kokuin/token": "^0.5.0",
20
- "@noble/hashes": "^2.3.0",
20
+ "@kubun/connector": "^0.14.0",
21
+ "@kubun/credential": "^0.14.1",
22
+ "@kubun/credential-types": "^0.14.0",
23
+ "@kubun/db": "^0.14.0",
24
+ "@kubun/engine": "^0.14.1",
25
+ "@kubun/graphql": "^0.14.1",
26
+ "@kubun/hlc": "^0.14.0",
27
+ "@kubun/id": "^0.14.0",
28
+ "@kubun/logger": "^0.14.0",
29
+ "@kubun/plugin-blob-api": "^0.14.0",
30
+ "@kubun/plugin-workflow-api": "^0.14.0",
31
+ "@kubun/protocol": "^0.14.1",
32
+ "@kubun/service-credential-api": "^0.14.0",
33
+ "@kubun/store-connector": "^0.14.0",
34
+ "@kubun/store-credential": "^0.14.0",
35
+ "@kubun/store-delegation": "^0.14.0",
36
+ "@kubun/store-graph": "^0.14.1",
37
+ "@noble/hashes": "^2.4.0",
21
38
  "@sozai/async": "^0.2.1",
22
39
  "@sozai/codec": "^0.4.0",
23
40
  "@sozai/event": "^0.1.3",
@@ -25,35 +42,28 @@
25
42
  "@sozai/runtime": "^0.1.0",
26
43
  "graphql": "^16.14.2",
27
44
  "graphql-scalars": "^2.0.0",
28
- "kysely": "^0.29.5",
29
- "@kubun/connector": "^0.13.1",
30
- "@kubun/hlc": "^0.13.0",
31
- "@kubun/db": "^0.13.0",
32
- "@kubun/engine": "^0.13.1",
33
- "@kubun/logger": "^0.13.0",
34
- "@kubun/graphql": "^0.13.1",
35
- "@kubun/credential": "^0.13.0",
36
- "@kubun/store-connector": "^0.13.0",
37
- "@kubun/store-credential": "^0.13.0",
38
- "@kubun/id": "^0.13.0",
39
- "@kubun/store-graph": "^0.13.2",
40
- "@kubun/store-delegation": "^0.13.0",
41
- "@kubun/protocol": "^0.13.1"
45
+ "kysely": "^0.29.5"
42
46
  },
43
47
  "devDependencies": {
48
+ "@enkaku/protocol": "^0.21.0",
49
+ "@enkaku/transport": "^0.21.0",
44
50
  "@kokuin/controller": "^0.1.0",
51
+ "@kubun/blob-backend": "^0.14.0",
52
+ "@kubun/connector-google-mail": "^0.14.0",
53
+ "@kubun/db-postgres": "^0.14.0",
54
+ "@kubun/models": "^0.14.0",
55
+ "@kubun/plugin-blob": "^0.14.0",
56
+ "@kubun/plugin-credential": "^0.14.0",
57
+ "@kubun/plugin-service-client": "^0.14.0",
58
+ "@kubun/plugin-service-server": "^0.14.0",
59
+ "@kubun/plugin-workflow": "^0.14.0",
60
+ "@kubun/service-controller-log-api": "^0.14.0",
61
+ "@kubun/store-blob": "^0.14.0",
62
+ "@kubun/store-controller": "^0.14.0",
63
+ "@kubun/store-workflow": "^0.14.0",
64
+ "@kubun/test-utils": "^0.13.0",
45
65
  "@testcontainers/postgresql": "^12.1.0",
46
- "get-port": "^7.2.0",
47
- "@kubun/blob-backend": "^0.13.0",
48
- "@kubun/db-postgres": "^0.13.0",
49
- "@kubun/connector-google-mail": "^0.13.0",
50
- "@kubun/models": "^0.13.0",
51
- "@kubun/plugin-blob": "^0.13.0",
52
- "@kubun/plugin-workflow": "^0.13.0",
53
- "@kubun/store-controller": "^0.13.0",
54
- "@kubun/store-blob": "^0.13.0",
55
- "@kubun/store-workflow": "^0.13.0",
56
- "@kubun/test-utils": "^0.13.0"
66
+ "get-port": "^7.2.0"
57
67
  },
58
68
  "publishConfig": {
59
69
  "access": "public"