@kubun/plugin-connector 0.12.1 → 0.13.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 +7 -1
- package/lib/action.js +7 -4
- package/lib/api.d.ts +57 -6
- package/lib/api.js +110 -24
- package/lib/credential.d.ts +7 -1
- package/lib/credential.js +25 -3
- package/lib/index.d.ts +1 -3
- package/lib/index.js +138 -23
- package/lib/manager.d.ts +0 -2
- package/lib/manager.js +1 -2
- package/lib/oauth.js +31 -5
- package/lib/schema.d.ts +18 -0
- package/lib/schema.js +3 -1
- package/lib/sync/engine.d.ts +8 -3
- package/lib/sync/engine.js +9 -3
- package/lib/sync/orchestrate.d.ts +8 -1
- package/lib/sync/orchestrate.js +18 -13
- package/lib/sync/state.d.ts +0 -2
- package/lib/sync/state.js +0 -6
- package/lib/sync/workflow.d.ts +59 -0
- package/lib/sync/workflow.js +241 -0
- package/lib/wrappable.d.ts +26 -0
- package/lib/wrappable.js +36 -0
- package/package.json +27 -15
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { SyncEngine } from './engine.js';
|
|
2
|
+
import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
3
|
+
/** Queue lane (and workflow name) for connector sync. */ export const CONNECTOR_SYNC_WORKFLOW = 'connector-sync';
|
|
4
|
+
/** How many connector syncs may run at once on this peer. */ export const CONNECTOR_SYNC_CONCURRENCY = 4;
|
|
5
|
+
/**
|
|
6
|
+
* Build the `connector-sync` workflow definition. One sync pass is a self-looping
|
|
7
|
+
* flow: `start` validates + seeds state, `batch` fetches+imports ONE provider
|
|
8
|
+
* batch per durable tick and advances the checkpoint, `finalize` marks idle. The
|
|
9
|
+
* workflow engine checkpoints the row and renews the lease between ticks, so the
|
|
10
|
+
* hand-rolled per-batch lease heartbeat is gone; the engine's crash-recovery
|
|
11
|
+
* re-drives `batch` from the persisted checkpoint.
|
|
12
|
+
*
|
|
13
|
+
* Each call returns a definition with its OWN in-process session map, so two
|
|
14
|
+
* definitions (two "boots" over one DB) do not share iterators — which is what
|
|
15
|
+
* makes crash-recovery re-open the provider from the checkpoint.
|
|
16
|
+
*/ export function createConnectorSyncWorkflow(params) {
|
|
17
|
+
const { registry, credentialProvider, stateStore, syncEventEmitter, stores, mutateDocuments, logger } = params;
|
|
18
|
+
const sessions = new Map();
|
|
19
|
+
const sessionKey = (state)=>`${state.connectorName}:${state.ownerDID}`;
|
|
20
|
+
const effectiveBoundary = params.boundary ?? {
|
|
21
|
+
maxAge: 'P90D'
|
|
22
|
+
};
|
|
23
|
+
async function reportError(connectorName, ownerDID, checkpoint, lastSyncedAt, entityCount, err) {
|
|
24
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
25
|
+
sessions.delete(`${connectorName}:${ownerDID}`);
|
|
26
|
+
await stateStore.set(connectorName, ownerDID, {
|
|
27
|
+
checkpoint,
|
|
28
|
+
lastSyncedAt,
|
|
29
|
+
entityCount,
|
|
30
|
+
status: 'error',
|
|
31
|
+
error
|
|
32
|
+
});
|
|
33
|
+
await syncEventEmitter.emitSyncEvent({
|
|
34
|
+
type: 'error',
|
|
35
|
+
connectorName,
|
|
36
|
+
error
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const start = async (ctx)=>{
|
|
40
|
+
const { connectorName, ownerDID, full } = ctx.params;
|
|
41
|
+
try {
|
|
42
|
+
const connector = registry.get(connectorName);
|
|
43
|
+
if (connector == null) {
|
|
44
|
+
throw new Error(`Connector "${connectorName}" not found`);
|
|
45
|
+
}
|
|
46
|
+
const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
|
|
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`);
|
|
53
|
+
}
|
|
54
|
+
const existing = await stateStore.get(connectorName, ownerDID);
|
|
55
|
+
const isIncremental = !full && existing?.checkpoint != null;
|
|
56
|
+
const checkpoint = existing?.checkpoint ?? null;
|
|
57
|
+
const lastSyncedAt = existing?.lastSyncedAt ?? new Date().toISOString();
|
|
58
|
+
const baseEntityCount = existing?.entityCount ?? 0;
|
|
59
|
+
await stateStore.set(connectorName, ownerDID, {
|
|
60
|
+
checkpoint,
|
|
61
|
+
lastSyncedAt,
|
|
62
|
+
entityCount: baseEntityCount,
|
|
63
|
+
status: 'syncing'
|
|
64
|
+
});
|
|
65
|
+
await syncEventEmitter.emitSyncEvent({
|
|
66
|
+
type: 'started',
|
|
67
|
+
connectorName
|
|
68
|
+
});
|
|
69
|
+
const state = {
|
|
70
|
+
connectorName,
|
|
71
|
+
ownerDID,
|
|
72
|
+
full,
|
|
73
|
+
phase: isIncremental ? 'incremental' : 'initial',
|
|
74
|
+
checkpoint,
|
|
75
|
+
baseEntityCount,
|
|
76
|
+
totalProcessed: 0,
|
|
77
|
+
totalFailed: 0,
|
|
78
|
+
batchNumber: 0,
|
|
79
|
+
startTime: Date.now(),
|
|
80
|
+
lastSyncedAt,
|
|
81
|
+
failedSample: []
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
status: 'action',
|
|
85
|
+
state,
|
|
86
|
+
action: {
|
|
87
|
+
name: 'batch'
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await reportError(connectorName, ownerDID, null, new Date().toISOString(), 0, err);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const batch = async (ctx)=>{
|
|
96
|
+
const state = ctx.state;
|
|
97
|
+
const key = sessionKey(state);
|
|
98
|
+
try {
|
|
99
|
+
let session = sessions.get(key);
|
|
100
|
+
if (session == null) {
|
|
101
|
+
// First batch, or a fresh process after a crash: (re)open the provider
|
|
102
|
+
// from the durable checkpoint. Re-processing an already-imported batch is
|
|
103
|
+
// idempotent (imports upsert by unique key).
|
|
104
|
+
const connector = registry.get(state.connectorName);
|
|
105
|
+
if (connector == null) {
|
|
106
|
+
throw new Error(`Connector "${state.connectorName}" not found`);
|
|
107
|
+
}
|
|
108
|
+
const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
|
|
109
|
+
const credential = providerName ? await credentialProvider.get(providerName, state.ownerDID) : null;
|
|
110
|
+
if (connector.serverProvider == null) {
|
|
111
|
+
throw new Error(`Connector "${state.connectorName}" has no server provider`);
|
|
112
|
+
}
|
|
113
|
+
const provider = connector.serverProvider({
|
|
114
|
+
credential: credential ?? {
|
|
115
|
+
accessToken: '',
|
|
116
|
+
scopes: []
|
|
117
|
+
},
|
|
118
|
+
boundary: effectiveBoundary
|
|
119
|
+
});
|
|
120
|
+
const engine = new SyncEngine({
|
|
121
|
+
stores,
|
|
122
|
+
stateStore,
|
|
123
|
+
connectorName: state.connectorName,
|
|
124
|
+
clusters: connector.clusters,
|
|
125
|
+
logger,
|
|
126
|
+
mutateDocuments
|
|
127
|
+
});
|
|
128
|
+
const iterable = state.phase === 'incremental' && state.checkpoint != null ? provider.fetchChanges({
|
|
129
|
+
boundary: effectiveBoundary,
|
|
130
|
+
checkpoint: state.checkpoint,
|
|
131
|
+
lastSyncedAt: state.lastSyncedAt ? new Date(state.lastSyncedAt) : undefined,
|
|
132
|
+
signal: ctx.signal
|
|
133
|
+
}) : provider.fetchAll({
|
|
134
|
+
boundary: effectiveBoundary,
|
|
135
|
+
checkpoint: state.checkpoint ?? undefined,
|
|
136
|
+
signal: ctx.signal
|
|
137
|
+
});
|
|
138
|
+
session = {
|
|
139
|
+
engine,
|
|
140
|
+
iterator: iterable[Symbol.asyncIterator]()
|
|
141
|
+
};
|
|
142
|
+
sessions.set(key, session);
|
|
143
|
+
}
|
|
144
|
+
const next = await session.iterator.next();
|
|
145
|
+
if (next.done || next.value == null) {
|
|
146
|
+
sessions.delete(key);
|
|
147
|
+
return {
|
|
148
|
+
status: 'action',
|
|
149
|
+
state,
|
|
150
|
+
action: {
|
|
151
|
+
name: 'finalize'
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const entityBatch = next.value;
|
|
156
|
+
const result = await session.engine.processBatch(entityBatch, state.ownerDID);
|
|
157
|
+
const totalProcessed = state.totalProcessed + result.created + result.updated + result.deleted;
|
|
158
|
+
const totalFailed = state.totalFailed + result.failed;
|
|
159
|
+
const failedSample = state.failedSample.slice();
|
|
160
|
+
for (const err of result.errors){
|
|
161
|
+
if (failedSample.length >= MAX_LOGGED_ERRORS) break;
|
|
162
|
+
failedSample.push(err);
|
|
163
|
+
}
|
|
164
|
+
const checkpoint = entityBatch.checkpoint != null ? entityBatch.checkpoint : state.checkpoint;
|
|
165
|
+
const lastSyncedAt = new Date().toISOString();
|
|
166
|
+
await stateStore.set(state.connectorName, state.ownerDID, {
|
|
167
|
+
checkpoint,
|
|
168
|
+
lastSyncedAt,
|
|
169
|
+
entityCount: state.baseEntityCount + totalProcessed,
|
|
170
|
+
status: 'syncing'
|
|
171
|
+
});
|
|
172
|
+
await syncEventEmitter.emitSyncEvent({
|
|
173
|
+
type: 'progress',
|
|
174
|
+
connectorName: state.connectorName,
|
|
175
|
+
entitiesProcessed: totalProcessed,
|
|
176
|
+
entitiesFailed: totalFailed
|
|
177
|
+
});
|
|
178
|
+
const nextState = {
|
|
179
|
+
...state,
|
|
180
|
+
checkpoint,
|
|
181
|
+
totalProcessed,
|
|
182
|
+
totalFailed,
|
|
183
|
+
batchNumber: state.batchNumber + 1,
|
|
184
|
+
lastSyncedAt,
|
|
185
|
+
failedSample
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
status: 'action',
|
|
189
|
+
state: nextState,
|
|
190
|
+
action: {
|
|
191
|
+
name: 'batch'
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
} catch (err) {
|
|
195
|
+
await reportError(state.connectorName, state.ownerDID, state.checkpoint, state.lastSyncedAt, state.baseEntityCount + state.totalProcessed, err);
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const finalize = async (ctx)=>{
|
|
200
|
+
const state = ctx.state;
|
|
201
|
+
try {
|
|
202
|
+
const lastSyncedAt = new Date().toISOString();
|
|
203
|
+
await stateStore.set(state.connectorName, state.ownerDID, {
|
|
204
|
+
checkpoint: state.checkpoint,
|
|
205
|
+
lastSyncedAt,
|
|
206
|
+
entityCount: state.baseEntityCount + state.totalProcessed,
|
|
207
|
+
status: 'idle'
|
|
208
|
+
});
|
|
209
|
+
await syncEventEmitter.emitSyncEvent({
|
|
210
|
+
type: 'completed',
|
|
211
|
+
connectorName: state.connectorName,
|
|
212
|
+
totalProcessed: state.totalProcessed,
|
|
213
|
+
totalFailed: state.totalFailed,
|
|
214
|
+
duration: Date.now() - state.startTime,
|
|
215
|
+
...state.failedSample.length > 0 ? {
|
|
216
|
+
failedSample: state.failedSample
|
|
217
|
+
} : {}
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
status: 'end',
|
|
221
|
+
state
|
|
222
|
+
};
|
|
223
|
+
} catch (err) {
|
|
224
|
+
await reportError(state.connectorName, state.ownerDID, state.checkpoint, state.lastSyncedAt, state.baseEntityCount + state.totalProcessed, err);
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
return {
|
|
229
|
+
definition: {
|
|
230
|
+
name: CONNECTOR_SYNC_WORKFLOW,
|
|
231
|
+
initialAction: {
|
|
232
|
+
name: 'start'
|
|
233
|
+
},
|
|
234
|
+
handlers: {
|
|
235
|
+
start,
|
|
236
|
+
batch,
|
|
237
|
+
finalize
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Identity, MethodRegistry } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* The spelling of a DID that can be encrypted to.
|
|
4
|
+
*
|
|
5
|
+
* `Identity` declares `id` alone, but a `did:peer:4` identity is a
|
|
6
|
+
* `MultiKeyIdentity` and carries `longForm` on the value. The short form
|
|
7
|
+
* resolves no document and so no agreement key — `deriveSharedSecret` refuses it
|
|
8
|
+
* outright, which is the good failure. The bad one is a key derived from the
|
|
9
|
+
* wrong branch, which throws nothing and yields ciphertext nobody can open.
|
|
10
|
+
*
|
|
11
|
+
* For `did:key` the two forms are the same string, so this is the identity's own
|
|
12
|
+
* `id` on every deployment Kubun ships today.
|
|
13
|
+
*/
|
|
14
|
+
export declare function wrappableDID(identity: Identity): string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a key could be wrapped to this DID via the resolver-backed KEM.
|
|
17
|
+
*
|
|
18
|
+
* Asks the resolver rather than the spelling: this is the exact call a `did`
|
|
19
|
+
* factor makes when a credential key is minted, so the answer cannot drift from
|
|
20
|
+
* what the mint will do. A structural "is this a peer:4 short form?" test would,
|
|
21
|
+
* and would one day refuse a DID that works. A `did:kokuin` recipient resolves
|
|
22
|
+
* only when its resolver is passed in `methods`; without one it fails closed.
|
|
23
|
+
*
|
|
24
|
+
* The derived secret is discarded — resolving the recipient is the whole answer.
|
|
25
|
+
*/
|
|
26
|
+
export declare function canWrapTo(did: string, methods?: MethodRegistry): Promise<boolean>;
|
package/lib/wrappable.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { deriveSharedSecretAsync } from '@kokuin/jwe';
|
|
2
|
+
/**
|
|
3
|
+
* The spelling of a DID that can be encrypted to.
|
|
4
|
+
*
|
|
5
|
+
* `Identity` declares `id` alone, but a `did:peer:4` identity is a
|
|
6
|
+
* `MultiKeyIdentity` and carries `longForm` on the value. The short form
|
|
7
|
+
* resolves no document and so no agreement key — `deriveSharedSecret` refuses it
|
|
8
|
+
* outright, which is the good failure. The bad one is a key derived from the
|
|
9
|
+
* wrong branch, which throws nothing and yields ciphertext nobody can open.
|
|
10
|
+
*
|
|
11
|
+
* For `did:key` the two forms are the same string, so this is the identity's own
|
|
12
|
+
* `id` on every deployment Kubun ships today.
|
|
13
|
+
*/ export function wrappableDID(identity) {
|
|
14
|
+
const longForm = identity.longForm;
|
|
15
|
+
return typeof longForm === 'string' ? longForm : identity.id;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether a key could be wrapped to this DID via the resolver-backed KEM.
|
|
19
|
+
*
|
|
20
|
+
* Asks the resolver rather than the spelling: this is the exact call a `did`
|
|
21
|
+
* factor makes when a credential key is minted, so the answer cannot drift from
|
|
22
|
+
* what the mint will do. A structural "is this a peer:4 short form?" test would,
|
|
23
|
+
* and would one day refuse a DID that works. A `did:kokuin` recipient resolves
|
|
24
|
+
* only when its resolver is passed in `methods`; without one it fails closed.
|
|
25
|
+
*
|
|
26
|
+
* The derived secret is discarded — resolving the recipient is the whole answer.
|
|
27
|
+
*/ export async function canWrapTo(did, methods) {
|
|
28
|
+
try {
|
|
29
|
+
await deriveSharedSecretAsync(did, {
|
|
30
|
+
methods: methods ?? []
|
|
31
|
+
});
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
"LICENSE.md"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@kokuin/capability": "^0.
|
|
18
|
-
"@kokuin/
|
|
17
|
+
"@kokuin/capability": "^0.3.0",
|
|
18
|
+
"@kokuin/jwe": "^0.1.0",
|
|
19
|
+
"@kokuin/token": "^0.5.0",
|
|
19
20
|
"@noble/hashes": "^2.3.0",
|
|
20
21
|
"@sozai/async": "^0.2.1",
|
|
21
22
|
"@sozai/codec": "^0.4.0",
|
|
@@ -23,25 +24,36 @@
|
|
|
23
24
|
"@sozai/generator": "^0.2.0",
|
|
24
25
|
"@sozai/runtime": "^0.1.0",
|
|
25
26
|
"graphql": "^16.14.2",
|
|
26
|
-
"graphql-scalars": "^
|
|
27
|
+
"graphql-scalars": "^2.0.0",
|
|
27
28
|
"kysely": "^0.29.5",
|
|
28
|
-
"@kubun/
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/
|
|
31
|
-
"@kubun/hlc": "^0.12.0",
|
|
29
|
+
"@kubun/credential": "^0.13.0",
|
|
30
|
+
"@kubun/engine": "^0.13.0",
|
|
31
|
+
"@kubun/db": "^0.13.0",
|
|
32
32
|
"@kubun/graphql": "^0.13.0",
|
|
33
|
-
"@kubun/
|
|
34
|
-
"@kubun/
|
|
33
|
+
"@kubun/logger": "^0.13.0",
|
|
34
|
+
"@kubun/id": "^0.13.0",
|
|
35
35
|
"@kubun/protocol": "^0.13.0",
|
|
36
|
-
"@kubun/store-
|
|
37
|
-
"@kubun/
|
|
38
|
-
"@kubun/store-graph": "^0.13.0"
|
|
36
|
+
"@kubun/store-credential": "^0.13.0",
|
|
37
|
+
"@kubun/hlc": "^0.13.0",
|
|
38
|
+
"@kubun/store-graph": "^0.13.0",
|
|
39
|
+
"@kubun/store-connector": "^0.13.0",
|
|
40
|
+
"@kubun/connector": "^0.13.0",
|
|
41
|
+
"@kubun/store-delegation": "^0.13.0"
|
|
39
42
|
},
|
|
40
43
|
"devDependencies": {
|
|
44
|
+
"@kokuin/controller": "^0.1.0",
|
|
41
45
|
"@testcontainers/postgresql": "^12.1.0",
|
|
42
46
|
"get-port": "^7.2.0",
|
|
43
|
-
"@kubun/
|
|
44
|
-
"@kubun/
|
|
47
|
+
"@kubun/db-postgres": "^0.13.0",
|
|
48
|
+
"@kubun/blob-backend": "^0.13.0",
|
|
49
|
+
"@kubun/connector-google-mail": "^0.13.0",
|
|
50
|
+
"@kubun/plugin-blob": "^0.13.0",
|
|
51
|
+
"@kubun/plugin-workflow": "^0.13.0",
|
|
52
|
+
"@kubun/store-blob": "^0.13.0",
|
|
53
|
+
"@kubun/models": "^0.13.0",
|
|
54
|
+
"@kubun/store-workflow": "^0.13.0",
|
|
55
|
+
"@kubun/test-utils": "^0.13.0",
|
|
56
|
+
"@kubun/store-controller": "^0.13.0"
|
|
45
57
|
},
|
|
46
58
|
"publishConfig": {
|
|
47
59
|
"access": "public"
|