@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.
@@ -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>;
@@ -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.12.1",
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.2.2",
18
- "@kokuin/token": "^0.4.0",
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": "^1.26.0",
27
+ "graphql-scalars": "^2.0.0",
27
28
  "kysely": "^0.29.5",
28
- "@kubun/connector": "^0.12.1",
29
- "@kubun/db": "^0.12.1",
30
- "@kubun/engine": "^0.12.1",
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/id": "^0.12.0",
34
- "@kubun/logger": "^0.12.0",
33
+ "@kubun/logger": "^0.13.0",
34
+ "@kubun/id": "^0.13.0",
35
35
  "@kubun/protocol": "^0.13.0",
36
- "@kubun/store-connector": "^0.12.1",
37
- "@kubun/store-delegation": "^0.12.1",
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/test-utils": "^0.12.0",
44
- "@kubun/db-postgres": "^0.12.1"
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"