@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.
- package/lib/action.d.ts +2 -1
- package/lib/api.d.ts +2 -54
- package/lib/api.js +1 -116
- package/lib/credential.d.ts +2 -22
- package/lib/credential.js +4 -129
- package/lib/index.d.ts +6 -3
- package/lib/index.js +244 -52
- package/lib/oauth.d.ts +5 -3
- package/lib/oauth.js +7 -2
- package/lib/schema.d.ts +5 -0
- package/lib/schema.js +53 -0
- package/lib/sync/orchestrate.d.ts +2 -1
- package/lib/sync/orchestrate.js +112 -76
- package/lib/sync/workflow.d.ts +4 -1
- package/lib/sync/workflow.js +29 -6
- package/lib/write-grants.d.ts +1 -1
- package/package.json +37 -27
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,97 @@ 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
|
+
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: '
|
|
130
|
+
status: 'started'
|
|
30
131
|
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
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
|
}
|
package/lib/sync/workflow.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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 = {
|
package/lib/sync/workflow.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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);
|
package/lib/write-grants.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CredentialProvider } from '@kubun/
|
|
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.
|
|
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
|
-
"@
|
|
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"
|