@kubun/plugin-connector 0.13.1 → 0.14.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 CHANGED
@@ -1,4 +1,5 @@
1
- import type { CredentialProvider, EntityRecord, WriteAttachment } from '@kubun/connector';
1
+ import type { EntityRecord, WriteAttachment } 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';
4
5
  import type { ConnectorRegistry } from './registry.js';
package/lib/api.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { type MethodRegistry } from '@kokuin/token';
2
- import { type CredentialManager } from '@kubun/credential';
3
1
  import type { StoreProvider } from '@kubun/db';
4
2
  import type { Logger } from '@kubun/logger';
5
3
  import { type PendingAuthRecord } from '@kubun/store-connector';
4
+ export type { CreateCredentialAPIParams, CredentialAPI } from '@kubun/credential';
5
+ export { createCredentialAPI } from '@kubun/credential';
6
6
  export type SyncStateData = {
7
7
  connectorName: string;
8
8
  ownerDID: string;
@@ -28,70 +28,18 @@ export type SetSyncStateParams = {
28
28
  * two `owner_did` columns are equality keys, so an un-normalized one files the
29
29
  * same account under two rows: a credential invisible under one spelling and
30
30
  * un-revokable under the other, and a sync lease held twice at once.
31
- *
32
- * `ownerWrappableDID` is NOT normalized — see {@link createConnectorAPI}.
33
31
  */
34
32
  export type ConnectorAPI = {
35
33
  getSyncState: (connectorName: string, ownerDID: string) => Promise<SyncStateData | null>;
36
34
  setSyncState: (connectorName: string, ownerDID: string, state: SetSyncStateParams) => Promise<SyncStateData>;
37
35
  deleteSyncState: (connectorName: string, ownerDID: string) => Promise<void>;
38
36
  listSyncStates: (connectorName: string) => Promise<Array<SyncStateData>>;
39
- getCredential: (providerName: string, ownerDID: string) => Promise<string | null>;
40
- getCredentialProvenance: (providerName: string, ownerDID: string) => Promise<{
41
- updatedAt: string | null;
42
- writerDID: string | null;
43
- } | null>;
44
- /**
45
- * Re-encrypt an existing credential. Refuses when none exists, because
46
- * creating one is a different decision — see `createCredential`.
47
- */
48
- setCredential: (providerName: string, ownerDID: string, credential: string) => Promise<void>;
49
- /**
50
- * Mint the key, its two wrappings and the entry, and record the pointer.
51
- *
52
- * Split from `setCredential` so the headless refresh path cannot mint a key.
53
- * A minted key fixes who will ever be able to read it, and a refresh running
54
- * with no owner in hand would mint one wrapped to the server alone — locking
55
- * the account owner out of their own credential with nothing failing.
56
- */
57
- createCredential: (params: {
58
- providerName: string;
59
- ownerDID: string;
60
- ownerWrappableDID: string;
61
- controllerWrappableDID?: string;
62
- credential: string;
63
- }) => Promise<void>;
64
- deleteCredential: (providerName: string, ownerDID: string) => Promise<void>;
65
37
  createPendingAuth: (record: PendingAuthRecord) => Promise<void>;
66
38
  consumePendingAuth: (state: string) => Promise<PendingAuthRecord | null>;
67
39
  deleteExpiredPendingAuth: (cutoffMs: number) => Promise<void>;
68
- /**
69
- * The controller resolvers for this API's (request-scoped) transaction
70
- * provider — the exact `methods` the credential mint passes to `createKey`.
71
- * Exposed so the OAuth pre-flight can ask `canWrapTo` with the same resolver
72
- * the mint uses, rather than failing closed on a `did:kokuin:` owner whose
73
- * agreement key is resolvable only through the controller resolver.
74
- */
75
- getControllerMethods: () => MethodRegistry;
76
40
  };
77
41
  export type CreateConnectorAPIParams = {
78
42
  stores: StoreProvider;
79
43
  logger: Logger;
80
- /**
81
- * Takes the `StoreProvider` rather than a manager, because during a mutation
82
- * that provider is the transaction and a manager built over the base one
83
- * would read outside it.
84
- */
85
- getCredentialManager: (stores: StoreProvider) => Promise<CredentialManager>;
86
- /** The engine's own DID, in a form that can be encrypted to. */
87
- serverWrappableDID: string;
88
- /**
89
- * Resolvers for wrapping recipients that carry no key material of their own
90
- * (`did:kokuin:`), scoped to the request's transaction provider so a resolve
91
- * reads the mutation's tx, never a second connection. A self-contained
92
- * recipient resolves with an empty registry, so callers with no controller
93
- * recipient in play still pass `() => []`.
94
- */
95
- getControllerMethods: (stores: StoreProvider) => MethodRegistry;
96
44
  };
97
45
  export declare function createConnectorAPI(params: CreateConnectorAPIParams): ConnectorAPI;
package/lib/api.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
- import { CredentialSupersededBranch } from '@kubun/credential';
3
2
  import { getConnectorStore } from '@kubun/store-connector';
4
- import { fromUTF, toUTF } from '@sozai/codec';
3
+ export { createCredentialAPI } from '@kubun/credential';
5
4
  export function createConnectorAPI(params) {
6
5
  const getStore = ()=>getConnectorStore(params.stores);
7
- const getCredentials = ()=>params.getCredentialManager(params.stores);
8
6
  return {
9
7
  async getSyncState (connectorName, ownerDID) {
10
8
  const store = await getStore();
@@ -68,116 +66,6 @@ export function createConnectorAPI(params) {
68
66
  leaseExpiresAt: entry.leaseExpiresAt ?? null
69
67
  }));
70
68
  },
71
- async getCredential (providerName, ownerDID) {
72
- const store = await getStore();
73
- const entryID = await store.getCredentialEntryID(providerName, normalizeDID(ownerDID));
74
- if (entryID == null) return null;
75
- try {
76
- return toUTF(await (await getCredentials()).readEntry(entryID));
77
- } catch (error) {
78
- if (error instanceof CredentialSupersededBranch) {
79
- params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
80
- providerName,
81
- entryID
82
- });
83
- return null;
84
- }
85
- throw error;
86
- }
87
- },
88
- async getCredentialProvenance (providerName, ownerDID) {
89
- // Reflects the current pointer, not the winning branch — no superseded-branch guard
90
- // (it never decrypts). Callers must gate on getCredential() != null so a loser-branch
91
- // pointer's provenance is never surfaced.
92
- const store = await getStore();
93
- const entryID = await store.getCredentialEntryID(providerName, normalizeDID(ownerDID));
94
- if (entryID == null) return null;
95
- return (await getCredentials()).getEntryProvenance(entryID);
96
- },
97
- async setCredential (providerName, ownerDID, credential) {
98
- const store = await getStore();
99
- const entryID = await store.getCredentialEntryID(providerName, normalizeDID(ownerDID));
100
- if (entryID == null) {
101
- throw new Error(`No connector credential to update for provider "${providerName}"; create it through the authorization flow`);
102
- }
103
- // Opens the key through the server's own wrapping and rewrites the entry
104
- // under it. No key is minted and no wrapping is touched, so a refresh
105
- // cannot change who can read the credential.
106
- await (await getCredentials()).updateEntry(entryID, fromUTF(credential));
107
- },
108
- async createCredential (input) {
109
- const { providerName, ownerDID, ownerWrappableDID, credential } = input;
110
- const store = await getStore();
111
- const credentials = await getCredentials();
112
- // Wrapping to a DID needs only that DID's published key, so an engine that
113
- // cannot decrypt would mint this key perfectly and then be unable to read
114
- // it — a failure that surfaces at the first headless sync, far from here.
115
- // This is where `cipher == null` used to fail closed, and it still does.
116
- if (!credentials.availableFactors().includes('did')) {
117
- throw new Error(`Cannot create a connector credential for "${providerName}": this engine's identity cannot decrypt, so it could never read back what it stored`);
118
- }
119
- // Two wrappings from the first write, not one plus a later grant: the
120
- // server has to read this to run sync, and the owner has to be able to
121
- // rotate and revoke it. Owner is authority, a different question from who
122
- // can decrypt: when a controller is supplied it is the owner, so a device
123
- // revoke goes through the controller's capability chain rather than the
124
- // device self-authorizing; absent one the device owns its own key.
125
- //
126
- // The two spellings below are deliberately different questions about the
127
- // same identity, and NOT interchangeable: `owner_did` is an equality key
128
- // and so is normalized, while a recipient is encrypted to and must keep
129
- // its wrappable form. Normalizing a `did:peer:4` recipient hands the short
130
- // form to `deriveSharedSecret`, which resolves no document and no
131
- // agreement key — do not fold these together.
132
- const wrappings = [
133
- [
134
- {
135
- kind: 'did',
136
- recipientDID: ownerWrappableDID
137
- }
138
- ],
139
- [
140
- {
141
- kind: 'did',
142
- recipientDID: params.serverWrappableDID
143
- }
144
- ]
145
- ];
146
- // A recovery recipient (e.g. a controller / seed-holder), so the credential
147
- // survives device loss. Opt-in: absent when the caller supplied none. Kept
148
- // in its wrappable form for the same reason as the two above — it is
149
- // encrypted to, not compared against.
150
- if (input.controllerWrappableDID != null) {
151
- wrappings.push([
152
- {
153
- kind: 'did',
154
- recipientDID: input.controllerWrappableDID
155
- }
156
- ]);
157
- }
158
- // The controller owns the key when supplied (its canonical DID equals the
159
- // normalized wrappable form); the account pointer below stays keyed on the
160
- // device — ownership and account scoping are separate concerns.
161
- const keyOwnerDID = input.controllerWrappableDID != null ? normalizeDID(input.controllerWrappableDID) : normalizeDID(ownerDID);
162
- const keyID = await credentials.createKey({
163
- ownerDID: keyOwnerDID,
164
- wrappings,
165
- methods: params.getControllerMethods(params.stores)
166
- });
167
- const entryID = await credentials.putEntry(keyID, fromUTF(credential));
168
- await store.setCredentialEntryID(providerName, normalizeDID(ownerDID), entryID);
169
- },
170
- async deleteCredential (providerName, ownerDID) {
171
- const store = await getStore();
172
- const owner = normalizeDID(ownerDID);
173
- const entryID = await store.getCredentialEntryID(providerName, owner);
174
- await store.deleteCredentialEntryID(providerName, owner);
175
- if (entryID != null) {
176
- // The pointer and the entry go together. Leaving the entry would keep
177
- // the token readable by anyone who kept its id after a disconnect.
178
- await (await getCredentials()).deleteEntry(entryID);
179
- }
180
- },
181
69
  async createPendingAuth (record) {
182
70
  const store = await getStore();
183
71
  await store.createPendingAuth(record);
@@ -189,9 +77,6 @@ export function createConnectorAPI(params) {
189
77
  async deleteExpiredPendingAuth (cutoffMs) {
190
78
  const store = await getStore();
191
79
  await store.deleteExpiredPendingAuth(cutoffMs);
192
- },
193
- getControllerMethods () {
194
- return params.getControllerMethods(params.stores);
195
80
  }
196
81
  };
197
82
  }
@@ -1,22 +1,2 @@
1
- import type { Credential, CredentialProvider, OAuthProviderDefinition } from '@kubun/connector';
2
- import type { Runtime } from '@sozai/runtime';
3
- import type { ConnectorAPI } from './api.js';
4
- export type DBCredentialProviderParams = {
5
- api: ConnectorAPI;
6
- runtime: Runtime;
7
- providers: Array<OAuthProviderDefinition>;
8
- bufferSeconds?: number;
9
- };
10
- export declare class DBCredentialProvider implements CredentialProvider {
11
- #private;
12
- constructor(params: DBCredentialProviderParams);
13
- get(providerName: string, ownerDID: string): Promise<Credential | null>;
14
- set(params: {
15
- providerName: string;
16
- ownerDID: string;
17
- credential: Credential;
18
- ownerWrappableDID?: string;
19
- controllerWrappableDID?: string;
20
- }): Promise<void>;
21
- delete(providerName: string, ownerDID: string): Promise<void>;
22
- }
1
+ export type { DBCredentialProviderParams } from '@kubun/credential';
2
+ export { DBCredentialProvider } from '@kubun/credential';
package/lib/credential.js CHANGED
@@ -1,129 +1,4 @@
1
- const DEFAULT_BUFFER_SECONDS = 300;
2
- function serializeCredential(credential) {
3
- const serialized = {
4
- accessToken: credential.accessToken,
5
- scopes: credential.scopes
6
- };
7
- if (credential.refreshToken != null) {
8
- serialized.refreshToken = credential.refreshToken;
9
- }
10
- if (credential.expiresAt != null) {
11
- serialized.expiresAt = credential.expiresAt.toISOString();
12
- }
13
- if (credential.accountLabel != null) {
14
- serialized.accountLabel = credential.accountLabel;
15
- }
16
- if (credential.metadata != null) {
17
- serialized.metadata = credential.metadata;
18
- }
19
- return JSON.stringify(serialized);
20
- }
21
- function deserializeCredential(raw) {
22
- // ParseJSONResultsPlugin may have already parsed the JSON string
23
- const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
24
- const credential = {
25
- accessToken: parsed.accessToken,
26
- scopes: parsed.scopes
27
- };
28
- if (parsed.refreshToken != null) {
29
- credential.refreshToken = parsed.refreshToken;
30
- }
31
- if (parsed.expiresAt != null) {
32
- credential.expiresAt = new Date(parsed.expiresAt);
33
- }
34
- if (parsed.accountLabel != null) {
35
- credential.accountLabel = parsed.accountLabel;
36
- }
37
- if (parsed.metadata != null) {
38
- credential.metadata = parsed.metadata;
39
- }
40
- return credential;
41
- }
42
- export class DBCredentialProvider {
43
- #api;
44
- #runtime;
45
- #providers;
46
- #bufferMs;
47
- constructor(params){
48
- this.#api = params.api;
49
- this.#runtime = params.runtime;
50
- this.#providers = params.providers;
51
- this.#bufferMs = (params.bufferSeconds ?? DEFAULT_BUFFER_SECONDS) * 1000;
52
- }
53
- async get(providerName, ownerDID) {
54
- const raw = await this.#api.getCredential(providerName, ownerDID);
55
- if (raw == null) return null;
56
- const credential = deserializeCredential(raw);
57
- if (this.#isExpiringSoon(credential) && credential.refreshToken != null) {
58
- const refreshed = await this.#refresh(providerName, ownerDID, credential, credential.refreshToken);
59
- return refreshed ?? credential;
60
- }
61
- return credential;
62
- }
63
- async set(params) {
64
- const { providerName, ownerDID, credential, ownerWrappableDID, controllerWrappableDID } = params;
65
- const serialized = serializeCredential(credential);
66
- // Update when one exists, mint only when it does not. Dispatching on
67
- // presence rather than on the caller keeps the refresh path unable to mint
68
- // even when it does hold a wrappable DID.
69
- if (await this.#api.getCredential(providerName, ownerDID) != null) {
70
- await this.#api.setCredential(providerName, ownerDID, serialized);
71
- return;
72
- }
73
- if (ownerWrappableDID == null) {
74
- throw new Error(`Cannot create a connector credential for "${providerName}" without the owner's wrappable DID`);
75
- }
76
- await this.#api.createCredential({
77
- providerName,
78
- ownerDID,
79
- ownerWrappableDID,
80
- controllerWrappableDID,
81
- credential: serialized
82
- });
83
- }
84
- async delete(providerName, ownerDID) {
85
- await this.#api.deleteCredential(providerName, ownerDID);
86
- }
87
- #isExpiringSoon(credential) {
88
- if (credential.expiresAt == null) return false;
89
- return credential.expiresAt.getTime() - Date.now() < this.#bufferMs;
90
- }
91
- async #refresh(providerName, ownerDID, credential, refreshToken) {
92
- const provider = this.#providers.find((p)=>p.name === providerName);
93
- if (provider == null || provider.clientID == null || provider.clientSecret == null) {
94
- return null;
95
- }
96
- try {
97
- const response = await this.#runtime.fetch(provider.tokenEndpoint, {
98
- method: 'POST',
99
- headers: {
100
- 'Content-Type': 'application/x-www-form-urlencoded'
101
- },
102
- body: new URLSearchParams({
103
- grant_type: 'refresh_token',
104
- refresh_token: refreshToken,
105
- client_id: provider.clientID,
106
- client_secret: provider.clientSecret
107
- })
108
- });
109
- if (!response.ok) return null;
110
- const tokenData = await response.json();
111
- const refreshed = {
112
- accessToken: tokenData.access_token,
113
- refreshToken: tokenData.refresh_token ?? credential.refreshToken,
114
- expiresAt: tokenData.expires_in != null ? new Date(Date.now() + tokenData.expires_in * 1000) : undefined,
115
- scopes: tokenData.scope?.split(' ') ?? credential.scopes,
116
- accountLabel: credential.accountLabel,
117
- metadata: credential.metadata
118
- };
119
- await this.set({
120
- providerName,
121
- ownerDID,
122
- credential: refreshed
123
- });
124
- return refreshed;
125
- } catch {
126
- return null;
127
- }
128
- }
129
- }
1
+ // The DB-backed credential provider moved to @kubun/credential; re-exported here
2
+ // so existing importers of `../src/credential.js` and the package index keep
3
+ // resolving it.
4
+ export { DBCredentialProvider } from '@kubun/credential';
package/lib/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import type { ConnectorDefinition, OAuthProviderDefinition, SyncBoundary } from '@kubun/connector';
1
+ import type { ConnectorDefinition, SyncBoundary } from '@kubun/connector';
2
+ import type { OAuthProviderDefinition } from '@kubun/credential-types';
2
3
  import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
3
- import { type ConnectorAPI, type SetSyncStateParams, type SyncStateData } from './api.js';
4
+ import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
5
+ import { type ConnectorAPI, type CredentialAPI, type SetSyncStateParams, type SyncStateData } from './api.js';
4
6
  import { DBCredentialProvider, type DBCredentialProviderParams } from './credential.js';
5
7
  export { type ExecuteActionDeps, type ExecuteActionParams, type ExecuteActionResult, executeAction, } from './action.js';
6
8
  export { type ConnectorActivatedEvent, type ConnectorDeactivatedEvent, ConnectorManager, type ConnectorManagerEvents, type ConnectorManagerParams, } from './manager.js';
@@ -13,13 +15,14 @@ export { type OrchestrateSyncParams, type OrchestrateSyncResult, orchestrateSync
13
15
  export { EntityProcessor, type EntityProcessorParams, type MutateDocuments, type ProcessBatchResult, type SignedDocumentWriter, } from './sync/processor.js';
14
16
  export { DBSyncStateStore } from './sync/state.js';
15
17
  export { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, type ConnectorSyncWorkflowDefinition, type ConnectorSyncWorkflowParams, createConnectorSyncWorkflow, } from './sync/workflow.js';
16
- export type { ConnectorAPI, SetSyncStateParams, SyncStateData };
18
+ export type { ConnectorAPI, CredentialAPI, SetSyncStateParams, SyncStateData };
17
19
  export { DBCredentialProvider, type DBCredentialProviderParams };
18
20
  export type ConnectorPluginOptions = {
19
21
  connectors: Array<ConnectorDefinition>;
20
22
  providers?: Array<OAuthProviderDefinition>;
21
23
  defaults?: {
22
24
  boundary?: SyncBoundary;
25
+ periodicSyncPolicy?: AdaptivePolicy;
23
26
  };
24
27
  };
25
28
  export declare function createConnectorPlugin(options: ConnectorPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { createCredentialManager } from '@kubun/credential';
3
3
  import { connectorStoreDefinition } from '@kubun/store-connector';
4
4
  import { credentialStoreDefinition, getCredentialStore } from '@kubun/store-credential';
5
5
  import { executeAction } from './action.js';
6
- import { createConnectorAPI } from './api.js';
6
+ import { createConnectorAPI, createCredentialAPI } from './api.js';
7
7
  import { DBCredentialProvider } from './credential.js';
8
8
  import { ConnectorManager } from './manager.js';
9
9
  import { OAuthService } from './oauth.js';
@@ -26,13 +26,60 @@ export { EntityProcessor } from './sync/processor.js';
26
26
  export { DBSyncStateStore } from './sync/state.js';
27
27
  export { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, createConnectorSyncWorkflow } from './sync/workflow.js';
28
28
  export { DBCredentialProvider };
29
+ // 5m after a productive run; idle 15m→6h, error 1m→1h, offline 30s→15m.
30
+ const DEFAULT_PERIODIC_SYNC_POLICY = {
31
+ changed: 300000,
32
+ idle: {
33
+ base: 900000,
34
+ max: 21600000
35
+ },
36
+ error: {
37
+ base: 60000,
38
+ max: 3600000
39
+ },
40
+ offline: {
41
+ base: 30000,
42
+ max: 900000
43
+ }
44
+ };
45
+ // Present the standalone local stack as the unified surface: the credential
46
+ // provider for get/set/delete, its credential API for provenance. Service mode
47
+ // gets provenance as one more procedure, so both modes read it off one object.
48
+ function withProvenance(provider, api) {
49
+ return {
50
+ get: (providerName, ownerDID)=>provider.get(providerName, ownerDID),
51
+ set: (setParams)=>provider.set(setParams),
52
+ delete: (providerName, ownerDID)=>provider.delete(providerName, ownerDID),
53
+ getCredentialProvenance: (providerName, ownerDID)=>api.getCredentialProvenance(providerName, ownerDID)
54
+ };
55
+ }
29
56
  // ---- Connector state resolution ----
30
- async function resolveConnectorState(connectorAPI, registry, credentialProvider, viewerDID, connectorName) {
57
+ // Map a workflow instance status to the connector display status. A run in
58
+ // progress reads SYNCING, a failed terminal ERROR; every other terminal
59
+ // (completed/cancelled) is IDLE.
60
+ function displayStatusFor(status) {
61
+ if (status === 'pending' || status === 'running') return 'SYNCING';
62
+ if (status === 'failed') return 'ERROR';
63
+ return 'IDLE';
64
+ }
65
+ async function resolveConnectorState({ connectorAPI, registry, credentialProvider, getWorkflowAPI, viewerDID, connectorName }) {
31
66
  const connector = registry.get(connectorName);
32
67
  if (connector == null) {
33
68
  throw new Error(`Connector "${connectorName}" not found`);
34
69
  }
35
70
  const syncState = await connectorAPI.getSyncState(connectorName, viewerDID);
71
+ // The durable, lease-fenced truth: the workflow instance status. Preferred over
72
+ // the hand-written syncState.status row, which a zombie handler can transiently
73
+ // regress after crash-recovery. Falls back to that row only with no workflow
74
+ // plugin (local-only sync) or no instance yet.
75
+ let workflowStatus = null;
76
+ const workflowAPI = await getWorkflowAPI();
77
+ if (workflowAPI != null) {
78
+ const instance = await workflowAPI.getCurrentInstance(CONNECTOR_SYNC_WORKFLOW, `${connectorName}:${viewerDID}`);
79
+ if (instance != null) {
80
+ workflowStatus = displayStatusFor(instance.status);
81
+ }
82
+ }
36
83
  let authenticated = false;
37
84
  let hasWriteAccess = false;
38
85
  let authExpiresAt = null;
@@ -42,7 +89,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
42
89
  const credential = await credentialProvider.get(connector.auth.provider, viewerDID);
43
90
  if (credential != null) {
44
91
  authenticated = true;
45
- const provenance = await connectorAPI.getCredentialProvenance(connector.auth.provider, viewerDID);
92
+ const provenance = await credentialProvider.getCredentialProvenance(connector.auth.provider, viewerDID);
46
93
  credentialUpdatedAt = provenance?.updatedAt ?? null;
47
94
  credentialWriterDID = provenance?.writerDID ?? null;
48
95
  if (credential.expiresAt != null) {
@@ -59,7 +106,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
59
106
  if (syncState == null) {
60
107
  return {
61
108
  name: connectorName,
62
- status: 'IDLE',
109
+ status: workflowStatus ?? 'IDLE',
63
110
  authenticated,
64
111
  hasWriteAccess,
65
112
  authExpiresAt,
@@ -72,7 +119,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
72
119
  }
73
120
  return {
74
121
  name: connectorName,
75
- status: syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR',
122
+ status: workflowStatus ?? (syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR'),
76
123
  authenticated,
77
124
  hasWriteAccess,
78
125
  authExpiresAt,
@@ -123,39 +170,98 @@ export function createConnectorPlugin(options) {
123
170
  // One manager per StoreProvider: the request-scoped provider is the
124
171
  // mutation's transaction, and a manager built over the base provider would
125
172
  // read outside it. The key cache is per-manager, which is the same scope the
126
- // content key should have.
127
- const managers = new WeakMap();
128
- function getCredentialManager(stores) {
129
- let manager = managers.get(stores);
130
- if (manager == null) {
131
- manager = getCredentialStore(stores).then((store)=>createCredentialManager({
132
- store,
133
- identity,
134
- runtime: params.runtime,
135
- hlc: params.hlc
136
- }));
137
- managers.set(stores, manager);
138
- }
139
- return manager;
140
- }
173
+ // content key should have. Parameterised by the signing identity and HLC so a
174
+ // manager over an external credential graph stamps with that graph's writer,
175
+ // not the engine's.
176
+ const credentialManagerFor = (mgrIdentity, mgrHLC)=>{
177
+ const managers = new WeakMap();
178
+ return (stores)=>{
179
+ let manager = managers.get(stores);
180
+ if (manager == null) {
181
+ manager = getCredentialStore(stores).then((store)=>createCredentialManager({
182
+ store,
183
+ identity: mgrIdentity,
184
+ runtime: params.runtime,
185
+ hlc: mgrHLC
186
+ }));
187
+ managers.set(stores, manager);
188
+ }
189
+ return manager;
190
+ };
191
+ };
192
+ // The engine-scoped builder: still the manager for the engine's own request
193
+ // transaction on the no-backend path.
194
+ const getCredentialManager = credentialManagerFor(identity, params.hlc);
141
195
  // Controller resolvers for `did:kokuin:` wrapping recipients, scoped to the
142
- // request's transaction provider so a resolve reads the mutation's tx rather
143
- // than a second connection.
196
+ // resolved provider so a resolve reads that provider's controller store
197
+ // rather than a second connection.
144
198
  const getControllerMethods = (stores)=>[
145
199
  params.controllerResolverFor(stores)
146
200
  ];
201
+ // Mode is decided by provider presence, resolved once. A registered
202
+ // `credential-provider` (service mode) routes every credential op through the
203
+ // remote credential service; its absence (standalone) builds the local stack
204
+ // over the engine's own db, exactly as before. The registry gate closes after
205
+ // construction, so this promise settles lazily — memoized by capture.
206
+ const credentialServiceProviderPromise = params.engine.getProvider('credential-provider');
207
+ // The standalone credential stack over the engine's own db, built lazily and
208
+ // once — only ever touched when no service provider is registered. `stores`
209
+ // defaults to params.db for the background (workflow/orchestrate) paths, which
210
+ // run outside any request transaction. A request path passes its own tx.
211
+ let standaloneStack;
212
+ const getStandaloneStack = (stores = params.db)=>{
213
+ // The background stack is cached on params.db; a per-request tx builds a
214
+ // fresh stack so its reads and writes join that transaction.
215
+ if (stores === params.db && standaloneStack != null) return standaloneStack;
216
+ const api = createCredentialAPI({
217
+ stores,
218
+ logger,
219
+ getCredentialManager,
220
+ serverWrappableDID,
221
+ getControllerMethods
222
+ });
223
+ const stack = {
224
+ api,
225
+ provider: new DBCredentialProvider({
226
+ api,
227
+ runtime: params.runtime,
228
+ providers
229
+ })
230
+ };
231
+ if (stores === params.db) standaloneStack = stack;
232
+ return stack;
233
+ };
234
+ // Resolve the unified credential surface for a given store provider. Service
235
+ // mode reuses the registry provider as-is (plugin-scoped, not the request tx);
236
+ // standalone wraps the local stack, exposing provenance off its credential API.
237
+ const resolveCredentialSurface = async (stores)=>{
238
+ const serviceProvider = await credentialServiceProviderPromise;
239
+ if (serviceProvider != null) {
240
+ return {
241
+ provider: serviceProvider
242
+ };
243
+ }
244
+ const { api, provider } = getStandaloneStack(stores);
245
+ return {
246
+ provider: withProvenance(provider, api)
247
+ };
248
+ };
147
249
  const connectorAPI = createConnectorAPI({
148
250
  stores: params.db,
149
- logger,
150
- getCredentialManager,
151
- serverWrappableDID,
152
- getControllerMethods
153
- });
154
- const credentialProvider = new DBCredentialProvider({
155
- api: connectorAPI,
156
- runtime: params.runtime,
157
- providers
251
+ logger
158
252
  });
253
+ // The plugin-scope credential surface for the background paths (workflow,
254
+ // fallback orchestrate, OAuth callback fallback). It switches on mode per call
255
+ // so a service provider registered after construction is still honoured.
256
+ const backgroundCredentialProvider = {
257
+ get: async (providerName, ownerDID)=>(await resolveCredentialSurface(params.db)).provider.get(providerName, ownerDID),
258
+ set: async (setParams)=>{
259
+ await (await resolveCredentialSurface(params.db)).provider.set(setParams);
260
+ },
261
+ delete: async (providerName, ownerDID)=>{
262
+ await (await resolveCredentialSurface(params.db)).provider.delete(providerName, ownerDID);
263
+ }
264
+ };
159
265
  const stateStore = new DBSyncStateStore({
160
266
  api: connectorAPI
161
267
  });
@@ -168,7 +274,7 @@ export function createConnectorPlugin(options) {
168
274
  runtime: params.runtime,
169
275
  providers,
170
276
  registry,
171
- credentialProvider,
277
+ credentialProvider: backgroundCredentialProvider,
172
278
  connectorAPI
173
279
  });
174
280
  // The primary sync path: enqueue a durable `connector-sync` workflow whose
@@ -176,7 +282,7 @@ export function createConnectorPlugin(options) {
176
282
  // registered lazily the first time the workflow API resolves.
177
283
  const connectorSyncWorkflow = createConnectorSyncWorkflow({
178
284
  registry,
179
- credentialProvider,
285
+ credentialProvider: backgroundCredentialProvider,
180
286
  stateStore,
181
287
  syncEventEmitter: manager,
182
288
  stores: params.db,
@@ -209,28 +315,60 @@ export function createConnectorPlugin(options) {
209
315
  api: connectorAPI,
210
316
  createContextFactory: ()=>{
211
317
  return (ctx, stores)=>{
212
- // Per-request connector API and credential provider backed by the
213
- // request's StoreProvider (transactional during mutations).
318
+ // Per-request connector API backed by the request's StoreProvider
319
+ // (transactional during mutations). Sync-state stays Space-local, so
320
+ // this always rebuilds over the request tx.
214
321
  const requestAPI = createConnectorAPI({
215
322
  stores,
216
- logger,
217
- getCredentialManager,
218
- serverWrappableDID,
219
- getControllerMethods
220
- });
221
- const requestCredentialProvider = new DBCredentialProvider({
222
- api: requestAPI,
223
- runtime: params.runtime,
224
- providers
323
+ logger
225
324
  });
325
+ // Resolve the request's credential surface once. Service mode reuses the
326
+ // registry provider (not the request tx); standalone rebuilds the local
327
+ // stack over the request's transactional provider.
328
+ let surfacePromise;
329
+ const getSurface = ()=>{
330
+ if (surfacePromise == null) surfacePromise = resolveCredentialSurface(stores);
331
+ return surfacePromise;
332
+ };
333
+ // A base provider that defers to the resolved surface, so synchronous
334
+ // consumers (write-grants, executeAction) get one object while the
335
+ // mode resolves lazily behind it.
336
+ const requestProvider = {
337
+ get: async (providerName, ownerDID)=>(await getSurface()).provider.get(providerName, ownerDID),
338
+ set: async (setParams)=>{
339
+ await (await getSurface()).provider.set(setParams);
340
+ },
341
+ delete: async (providerName, ownerDID)=>{
342
+ await (await getSurface()).provider.delete(providerName, ownerDID);
343
+ },
344
+ getCredentialProvenance: async (providerName, ownerDID)=>(await getSurface()).provider.getCredentialProvenance(providerName, ownerDID)
345
+ };
226
346
  return {
227
- getState: (name)=>resolveConnectorState(requestAPI, registry, requestCredentialProvider, ctx.viewerDID, name),
347
+ getState: (name)=>resolveConnectorState({
348
+ connectorAPI: requestAPI,
349
+ registry,
350
+ credentialProvider: requestProvider,
351
+ getWorkflowAPI,
352
+ viewerDID: ctx.viewerDID,
353
+ connectorName: name
354
+ }),
228
355
  getStates: ()=>{
229
356
  const names = registry.list();
230
- return Promise.all(names.map((name)=>resolveConnectorState(requestAPI, registry, requestCredentialProvider, ctx.viewerDID, name)));
357
+ return Promise.all(names.map((name)=>resolveConnectorState({
358
+ connectorAPI: requestAPI,
359
+ registry,
360
+ credentialProvider: requestProvider,
361
+ getWorkflowAPI,
362
+ viewerDID: ctx.viewerDID,
363
+ connectorName: name
364
+ })));
231
365
  },
232
- startAuth: (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI),
233
- completeAuth: (args)=>oauthService.completeAuth(args, requestCredentialProvider, requestAPI),
366
+ // The owner pre-flight resolves against the engine's OWN controller
367
+ // methods, bound to the request tx (`stores`), in both modes. Using
368
+ // `params.db` here would open a second connection inside the
369
+ // startConnectorAuth mutation tx and deadlock single-connection SQLite.
370
+ startAuth: async (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI, getControllerMethods(stores)),
371
+ completeAuth: (args)=>oauthService.completeAuth(args, requestProvider, requestAPI),
234
372
  triggerSync: async (args)=>{
235
373
  if (!registry.has(args.connector)) {
236
374
  return {
@@ -264,7 +402,7 @@ export function createConnectorPlugin(options) {
264
402
  registry,
265
403
  syncEventEmitter: manager,
266
404
  stateStore,
267
- credentialProvider,
405
+ credentialProvider: backgroundCredentialProvider,
268
406
  ownerDID,
269
407
  stores: params.db,
270
408
  boundary: options.defaults?.boundary,
@@ -280,6 +418,45 @@ export function createConnectorPlugin(options) {
280
418
  };
281
419
  },
282
420
  subscribeToSyncEvents: (connector)=>subscribeToConnectorSyncEvents(manager.syncEvents, connector),
421
+ // Recurring-sync control. Viewer-at-enable: the schedule is registered
422
+ // under a viewer and its subject is `${connector}:${ownerDID}` —
423
+ // identical to the manual sync singletonKey, so scheduled and manual
424
+ // runs interlock. Scheduled fires later write with no viewer, via the
425
+ // engine-signed mutateDocuments path the workflow already uses.
426
+ enablePeriodicSync: async (connector, policyOverride)=>{
427
+ const workflowAPI = await getWorkflowAPI();
428
+ if (workflowAPI == null) {
429
+ throw new Error('workflow plugin required for periodic sync');
430
+ }
431
+ const ownerDID = ctx.viewerDID;
432
+ const subjectKey = `${connector}:${ownerDID}`;
433
+ const policy = policyOverride ?? options.defaults?.periodicSyncPolicy ?? DEFAULT_PERIODIC_SYNC_POLICY;
434
+ const { id } = await workflowAPI.scheduleAdaptive(CONNECTOR_SYNC_WORKFLOW, {
435
+ connectorName: connector,
436
+ ownerDID,
437
+ full: false
438
+ }, {
439
+ policy,
440
+ subjectKey
441
+ });
442
+ return workflowAPI.getPeriodicSync(id);
443
+ },
444
+ disablePeriodicSync: async (connector)=>{
445
+ const workflowAPI = await getWorkflowAPI();
446
+ if (workflowAPI == null) {
447
+ throw new Error('workflow plugin required for periodic sync');
448
+ }
449
+ const subjectKey = `${connector}:${ctx.viewerDID}`;
450
+ return workflowAPI.setPeriodicSyncEnabled(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`, false);
451
+ },
452
+ getPeriodicSync: async (connector)=>{
453
+ const workflowAPI = await getWorkflowAPI();
454
+ if (workflowAPI == null) {
455
+ throw new Error('workflow plugin required for periodic sync');
456
+ }
457
+ const subjectKey = `${connector}:${ctx.viewerDID}`;
458
+ return workflowAPI.getPeriodicSync(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`);
459
+ },
283
460
  executeAction: async (args, writeDocument)=>{
284
461
  try {
285
462
  // Bind the blob write to THIS request's transactional provider so
@@ -295,7 +472,7 @@ export function createConnectorPlugin(options) {
295
472
  }, {
296
473
  stores,
297
474
  registry,
298
- credentialProvider: requestCredentialProvider,
475
+ credentialProvider: requestProvider,
299
476
  ownerDID: ctx.viewerDID,
300
477
  logger,
301
478
  writeDocument,
@@ -327,7 +504,7 @@ export function createConnectorPlugin(options) {
327
504
  serverDID,
328
505
  viewerDID: ctx.viewerDID,
329
506
  stores,
330
- credentialProvider: requestCredentialProvider,
507
+ credentialProvider: requestProvider,
331
508
  hlc: params.hlc
332
509
  })
333
510
  };
package/lib/oauth.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { CredentialProvider, OAuthProviderDefinition } from '@kubun/connector';
1
+ import type { MethodRegistry } from '@kokuin/token';
2
+ import type { CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
2
3
  import type { Runtime } from '@sozai/runtime';
3
- import type { ConnectorAPI } from './api.js';
4
+ import type { ConnectorAPI, CredentialAPI } from './api.js';
4
5
  import type { ConnectorRegistry } from './registry.js';
5
6
  import type { CompleteConnectorAuthInput, CompleteConnectorAuthOutput, StartConnectorAuthInput, StartConnectorAuthOutput } from './schema.js';
6
7
  export type OAuthServiceParams = {
@@ -9,10 +10,11 @@ export type OAuthServiceParams = {
9
10
  registry: ConnectorRegistry;
10
11
  credentialProvider: CredentialProvider;
11
12
  connectorAPI: ConnectorAPI;
13
+ credentialAPI?: CredentialAPI;
12
14
  };
13
15
  export declare class OAuthService {
14
16
  #private;
15
17
  constructor(params: OAuthServiceParams);
16
- startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI): Promise<StartConnectorAuthOutput>;
18
+ startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI, preflightMethods?: MethodRegistry): Promise<StartConnectorAuthOutput>;
17
19
  completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<CompleteConnectorAuthOutput>;
18
20
  }
package/lib/oauth.js CHANGED
@@ -14,14 +14,18 @@ export class OAuthService {
14
14
  #providers;
15
15
  #registry;
16
16
  #connectorAPI;
17
+ #preflightMethods;
17
18
  constructor(params){
18
19
  this.#credentialProvider = params.credentialProvider;
19
20
  this.#runtime = params.runtime;
20
21
  this.#providers = params.providers;
21
22
  this.#registry = params.registry;
22
23
  this.#connectorAPI = params.connectorAPI;
24
+ this.#preflightMethods = params.credentialAPI?.getControllerMethods();
23
25
  }
24
- async startAuth(args, ownerDID, connectorAPI) {
26
+ async startAuth(args, ownerDID, connectorAPI, // The controller methods the owner pre-flight resolves against; `undefined`
27
+ // falls back to the constructor-captured methods (the direct unit tests).
28
+ preflightMethods) {
25
29
  // Prefer the request-scoped API so the pending-auth write joins the mutation
26
30
  // transaction; the base API would deadlock a single-connection SQLite tx.
27
31
  const api = connectorAPI ?? this.#connectorAPI;
@@ -45,7 +49,8 @@ export class OAuthService {
45
49
  // `did:kokuin:` owner's agreement key resolves only through the controller
46
50
  // resolver, and asking without it would fail closed and block a flow the
47
51
  // mint would have completed.
48
- if (!await canWrapTo(ownerWrappableDID, api.getControllerMethods())) {
52
+ const methods = preflightMethods ?? this.#preflightMethods;
53
+ if (methods != null && !await canWrapTo(ownerWrappableDID, methods)) {
49
54
  throw new Error(`Cannot start OAuth for "${args.provider}": nothing can be encrypted to "${ownerWrappableDID}", so the credential's owner could never read it. Pass \`viewerWrappableDID\` — for a did:peer:4 viewer that is the long form, which carries the agreement key.`);
50
55
  }
51
56
  // Carried to the mint like the owner's wrappable DID; it becomes a
package/lib/schema.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ConnectorSyncEventPayload } from '@kubun/connector';
2
2
  import type { SchemaExtension } from '@kubun/engine';
3
+ import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
3
4
  import type { EventEmitter } from '@sozai/event';
4
5
  import type { ConnectorRegistry } from './registry.js';
5
6
  import type { SignedDocumentWriter } from './sync/processor.js';
@@ -164,6 +165,9 @@ export type ConnectorQueryContext = {
164
165
  provider: string;
165
166
  }) => Promise<boolean>;
166
167
  connectorWriteGrants?: () => Promise<Array<ConnectorWriteGrant>>;
168
+ enablePeriodicSync?: (connector: string, policyOverride?: AdaptivePolicy) => Promise<unknown>;
169
+ disablePeriodicSync?: (connector: string) => Promise<unknown | null>;
170
+ getPeriodicSync?: (connector: string) => Promise<unknown | null>;
167
171
  };
168
172
  declare module '@kubun/graphql' {
169
173
  interface PluginContextMap {
@@ -172,6 +176,7 @@ declare module '@kubun/graphql' {
172
176
  }
173
177
  export type ConnectorExtensionConfig = {
174
178
  connectors?: Array<string>;
179
+ periodicSync?: boolean;
175
180
  };
176
181
  export declare function createConnectorSchemaExtension(params: {
177
182
  registry?: ConnectorRegistry;
package/lib/schema.js CHANGED
@@ -177,6 +177,22 @@ extend type Subscription {
177
177
  connectorSyncEvents(connector: String): ConnectorSyncEvent!
178
178
  }
179
179
  `);
180
+ // Recurring-sync control. Gated because it references `PeriodicSync`, which is
181
+ // defined by the co-deployed workflow plugin — referencing it on a connector-only
182
+ // graph would fail schema build with an unknown type.
183
+ const periodicSyncEnabled = params.config.periodicSync === true;
184
+ if (periodicSyncEnabled) {
185
+ sdlParts.push(`
186
+ extend type Query {
187
+ connectorPeriodicSync(connector: String!): PeriodicSync
188
+ }
189
+
190
+ extend type Mutation {
191
+ enableConnectorPeriodicSync(connector: String!, policy: JSON): PeriodicSync!
192
+ disableConnectorPeriodicSync(connector: String!): PeriodicSync
193
+ }
194
+ `);
195
+ }
180
196
  // Dynamic per-model action types and mutations from registered connectors
181
197
  const dynamicMutationLines = [];
182
198
  if (registry != null) {
@@ -284,6 +300,32 @@ input ConnectorUpdate${modelName}Input {
284
300
  return conn.disconnectProvider(args);
285
301
  }
286
302
  };
303
+ // Resolvers for the gated recurring-sync fields. Attached only when the SDL
304
+ // above declared them, since the resolver-attach step rejects a resolver with
305
+ // no matching field. The ownerDID is the viewer, captured by the context factory.
306
+ if (periodicSyncEnabled) {
307
+ queryFields.connectorPeriodicSync = (_source, args, context)=>{
308
+ const conn = requireConnector(context);
309
+ if (conn.getPeriodicSync == null) {
310
+ throw new Error('connector.getPeriodicSync is not available in this context');
311
+ }
312
+ return conn.getPeriodicSync(args.connector);
313
+ };
314
+ mutationFields.enableConnectorPeriodicSync = (_source, args, context)=>{
315
+ const conn = requireConnector(context);
316
+ if (conn.enablePeriodicSync == null) {
317
+ throw new Error('connector.enablePeriodicSync is not available in this context');
318
+ }
319
+ return conn.enablePeriodicSync(args.connector, args.policy);
320
+ };
321
+ mutationFields.disableConnectorPeriodicSync = (_source, args, context)=>{
322
+ const conn = requireConnector(context);
323
+ if (conn.disablePeriodicSync == null) {
324
+ throw new Error('connector.disablePeriodicSync is not available in this context');
325
+ }
326
+ return conn.disablePeriodicSync(args.connector);
327
+ };
328
+ }
287
329
  // Dynamic per-model action resolvers
288
330
  if (registry != null) {
289
331
  for (const connector of registry.getAll()){
@@ -360,6 +402,17 @@ input ConnectorUpdate${modelName}Input {
360
402
  };
361
403
  return {
362
404
  sdl,
405
+ // These write to the workflow store, not the graph, and must return the
406
+ // resulting projection synchronously. Running them inside `mutateGraph`'s
407
+ // write transaction would hold the shared single-connection DB across the
408
+ // workflow-store write and deadlock (as `syncConnector` defers its enqueue to
409
+ // `onCommit` for the same reason).
410
+ ...periodicSyncEnabled ? {
411
+ nonTransactionalMutationFields: [
412
+ 'enableConnectorPeriodicSync',
413
+ 'disableConnectorPeriodicSync'
414
+ ]
415
+ } : {},
363
416
  resolvers: {
364
417
  queryFields,
365
418
  mutationFields,
@@ -1,4 +1,5 @@
1
- import type { ConnectorSyncEventPayload, CredentialProvider, SyncBoundary, SyncStateStore } from '@kubun/connector';
1
+ import type { ConnectorSyncEventPayload, 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';
4
5
  import type { ConnectorRegistry } from '../registry.js';
@@ -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.0",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -17,6 +17,23 @@
17
17
  "@kokuin/capability": "^0.3.0",
18
18
  "@kokuin/jwe": "^0.1.0",
19
19
  "@kokuin/token": "^0.5.0",
20
+ "@kubun/connector": "^0.14.0",
21
+ "@kubun/credential": "^0.14.0",
22
+ "@kubun/credential-types": "^0.14.0",
23
+ "@kubun/db": "^0.14.0",
24
+ "@kubun/engine": "^0.14.0",
25
+ "@kubun/graphql": "^0.14.0",
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.0",
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.0",
20
37
  "@noble/hashes": "^2.3.0",
21
38
  "@sozai/async": "^0.2.1",
22
39
  "@sozai/codec": "^0.4.0",
@@ -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"