@kubun/plugin-p2p 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/index.js +100 -5
- package/lib/peer/blob-fetch.d.ts +2 -18
- package/lib/schema.d.ts +16 -1
- package/lib/schema.js +35 -3
- package/lib/sync/group-sync-workflow.d.ts +77 -0
- package/lib/sync/group-sync-workflow.js +96 -0
- package/lib/types.d.ts +10 -7
- package/lib/util/handler-error.d.ts +8 -5
- package/lib/util/handler-error.js +10 -23
- package/package.json +37 -33
package/lib/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { createP2PSchemaExtension } from './schema.js';
|
|
|
28
28
|
import { wireAccessDefaultSender } from './sync/access-default-sender.js';
|
|
29
29
|
import { createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG } from './sync/broadcast-queue.js';
|
|
30
30
|
import { DEFAULT_PUSH_SYNC_CONFIG, wireBroadcastSender } from './sync/broadcast-sender.js';
|
|
31
|
+
import { createGroupSyncWorkflow, GROUP_SYNC_CONCURRENCY, GROUP_SYNC_WORKFLOW, wireGroupPeriodicSyncArming } from './sync/group-sync-workflow.js';
|
|
31
32
|
import { createSyncHandlers } from './sync/handlers.js';
|
|
32
33
|
import { SyncManager } from './sync/sync-manager.js';
|
|
33
34
|
export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, requireGroupAdmin } from './context/require-admin.js';
|
|
@@ -76,6 +77,26 @@ export { SyncManager } from './sync/sync-manager.js';
|
|
|
76
77
|
function isOwnIdentity(identity) {
|
|
77
78
|
return isFullIdentity(identity) && 'privateKey' in identity;
|
|
78
79
|
}
|
|
80
|
+
// Fixed 30s after a pass that pulled mutations; idle 2m→30m, offline 1m→30m,
|
|
81
|
+
// error 30s→15m (both backing off exponentially). Group catch-up is a cheap
|
|
82
|
+
// merkle diff, so the productive cadence is tighter than the connector's.
|
|
83
|
+
const DEFAULT_GROUP_PERIODIC_SYNC_POLICY = {
|
|
84
|
+
changed: 30_000,
|
|
85
|
+
idle: {
|
|
86
|
+
base: 120_000,
|
|
87
|
+
max: 1_800_000
|
|
88
|
+
},
|
|
89
|
+
offline: {
|
|
90
|
+
base: 60_000,
|
|
91
|
+
max: 1_800_000,
|
|
92
|
+
backoff: 'exponential'
|
|
93
|
+
},
|
|
94
|
+
error: {
|
|
95
|
+
base: 30_000,
|
|
96
|
+
max: 900_000,
|
|
97
|
+
backoff: 'exponential'
|
|
98
|
+
}
|
|
99
|
+
};
|
|
79
100
|
export function createP2PPlugin(options) {
|
|
80
101
|
return (params)=>{
|
|
81
102
|
params.db.register(p2pStoreDefinition);
|
|
@@ -178,7 +199,7 @@ export function createP2PPlugin(options) {
|
|
|
178
199
|
logger: params.getLogger('peer-connections')
|
|
179
200
|
});
|
|
180
201
|
// The p2p plugin manages its own Enkaku server for sync handlers,
|
|
181
|
-
// separate from
|
|
202
|
+
// separate from the graph service's server.
|
|
182
203
|
const syncServers = [];
|
|
183
204
|
function createSyncTransport(signal) {
|
|
184
205
|
const directTransports = new DirectTransports({
|
|
@@ -390,8 +411,74 @@ export function createP2PPlugin(options) {
|
|
|
390
411
|
logger: params.getLogger('controller-handlers'),
|
|
391
412
|
autoAcceptPeers: options?.autoAcceptPeers
|
|
392
413
|
});
|
|
414
|
+
// Recurring group catch-up over the workflow engine's adaptive scheduler. One
|
|
415
|
+
// `catchUpWithBestPeer` pass is one durable tick, run viewer-independent through
|
|
416
|
+
// the SAME sync context the GraphQL surface and the API use, so a scheduled
|
|
417
|
+
// catch-up cannot ask for scopes an explicit one would not.
|
|
418
|
+
const groupSyncWorkflow = createGroupSyncWorkflow({
|
|
419
|
+
catchUpWithBestPeer: (groupID)=>createSyncContext(null, buildContextDeps(params.db)).catchUpWithBestPeer(groupID),
|
|
420
|
+
logger: params.getLogger('group-sync')
|
|
421
|
+
});
|
|
422
|
+
let workflowAPIPromise;
|
|
423
|
+
const getWorkflowAPI = ()=>{
|
|
424
|
+
if (workflowAPIPromise == null) {
|
|
425
|
+
workflowAPIPromise = params.engine.getAPI('workflow').then((api)=>{
|
|
426
|
+
api.defineQueue(GROUP_SYNC_WORKFLOW, {
|
|
427
|
+
concurrency: GROUP_SYNC_CONCURRENCY
|
|
428
|
+
});
|
|
429
|
+
api.register(groupSyncWorkflow.definition);
|
|
430
|
+
return api;
|
|
431
|
+
}).catch(()=>undefined);
|
|
432
|
+
}
|
|
433
|
+
return workflowAPIPromise;
|
|
434
|
+
};
|
|
435
|
+
// Resolve once at boot so the definition + queue register before any due
|
|
436
|
+
// schedule fires: an implicit schedule armed on a prior run is promoted by the
|
|
437
|
+
// engine at startup and needs its handler present, which the connector's
|
|
438
|
+
// lazy-only resolve does not have to guarantee (nothing schedules it implicitly).
|
|
439
|
+
void getWorkflowAPI();
|
|
440
|
+
// Implicit activation: arm a schedule the first time a group is joined,
|
|
441
|
+
// insert-if-absent (see `wireGroupPeriodicSyncArming`).
|
|
442
|
+
wireGroupPeriodicSyncArming({
|
|
443
|
+
emitter,
|
|
444
|
+
getWorkflowAPI,
|
|
445
|
+
policy: DEFAULT_GROUP_PERIODIC_SYNC_POLICY,
|
|
446
|
+
logger: params.getLogger('group-sync')
|
|
447
|
+
});
|
|
448
|
+
// Explicit control. `enable` always (re)activates — the deliberate opposite of
|
|
449
|
+
// the implicit insert-if-absent guard — so the user turning it back on wins.
|
|
450
|
+
const groupPeriodicSyncControl = {
|
|
451
|
+
enableGroupPeriodicSync: async (groupID, policyOverride)=>{
|
|
452
|
+
const api = await getWorkflowAPI();
|
|
453
|
+
if (api == null) {
|
|
454
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
455
|
+
}
|
|
456
|
+
const { id } = await api.scheduleAdaptive(GROUP_SYNC_WORKFLOW, {
|
|
457
|
+
groupID
|
|
458
|
+
}, {
|
|
459
|
+
policy: policyOverride ?? DEFAULT_GROUP_PERIODIC_SYNC_POLICY,
|
|
460
|
+
subjectKey: groupID
|
|
461
|
+
});
|
|
462
|
+
return api.getPeriodicSync(id);
|
|
463
|
+
},
|
|
464
|
+
disableGroupPeriodicSync: async (groupID)=>{
|
|
465
|
+
const api = await getWorkflowAPI();
|
|
466
|
+
if (api == null) {
|
|
467
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
468
|
+
}
|
|
469
|
+
return api.setPeriodicSyncEnabled(`${GROUP_SYNC_WORKFLOW}:${groupID}`, false);
|
|
470
|
+
},
|
|
471
|
+
getGroupPeriodicSync: async (groupID)=>{
|
|
472
|
+
const api = await getWorkflowAPI();
|
|
473
|
+
if (api == null) {
|
|
474
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
475
|
+
}
|
|
476
|
+
return api.getPeriodicSync(`${GROUP_SYNC_WORKFLOW}:${groupID}`);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
393
479
|
const pluginAPI = {
|
|
394
480
|
hubReady: hub.ready,
|
|
481
|
+
...groupPeriodicSyncControl,
|
|
395
482
|
addPeer: (config)=>syncManager.addPeer({
|
|
396
483
|
config,
|
|
397
484
|
stores: params.db
|
|
@@ -432,11 +519,19 @@ export function createP2PPlugin(options) {
|
|
|
432
519
|
peerConnections,
|
|
433
520
|
getBlobAPI: ()=>params.engine.getAPI('blob'),
|
|
434
521
|
fetch: params.runtime.fetch
|
|
435
|
-
}, attachmentID, options)
|
|
436
|
-
|
|
522
|
+
}, attachmentID, options)
|
|
523
|
+
};
|
|
524
|
+
// The controller resolver pulls a `did:kokuin:` log over `controller/get-log`
|
|
525
|
+
// from a connected group peer on a store miss/expiry. Registered as a
|
|
526
|
+
// `networked` provider (the engine times it out and folds the result) rather
|
|
527
|
+
// than an API member the engine reached by string.
|
|
528
|
+
params.registerProvider('controller-log-source', {
|
|
529
|
+
name: 'p2p',
|
|
530
|
+
transport: 'networked',
|
|
531
|
+
fetch: (did)=>fetchControllerLog({
|
|
437
532
|
peerConnections
|
|
438
533
|
}, did)
|
|
439
|
-
};
|
|
534
|
+
});
|
|
440
535
|
let httpSyncTransport;
|
|
441
536
|
let httpSyncServer;
|
|
442
537
|
let httpPeerTransport;
|
|
@@ -528,7 +623,7 @@ export function createP2PPlugin(options) {
|
|
|
528
623
|
}
|
|
529
624
|
return {
|
|
530
625
|
name: 'p2p',
|
|
531
|
-
schemaExtension: (
|
|
626
|
+
schemaExtension: (config)=>createP2PSchemaExtension(emitter, syncManager, params.getLogger('p2p-schema'), groupPeriodicSyncControl, config),
|
|
532
627
|
api: pluginAPI,
|
|
533
628
|
createContextFactory: ()=>{
|
|
534
629
|
return (ctx, stores)=>{
|
package/lib/peer/blob-fetch.d.ts
CHANGED
|
@@ -1,24 +1,8 @@
|
|
|
1
|
+
import type { BlobAPI } from '@kubun/plugin-blob-api';
|
|
1
2
|
import type { Fetch } from '@sozai/runtime';
|
|
2
3
|
import type { BlobManifestResult } from '../protocol.js';
|
|
3
4
|
import type { PeerConnection, PeerConnectionRegistry } from './connection-registry.js';
|
|
4
|
-
export type BlobFetchAPI =
|
|
5
|
-
getAttachment(attachmentID: string): Promise<{
|
|
6
|
-
state: string;
|
|
7
|
-
} | null>;
|
|
8
|
-
beginFetch(params: {
|
|
9
|
-
attachmentID: string;
|
|
10
|
-
chunkSize: number;
|
|
11
|
-
chunkDigests: Array<Uint8Array>;
|
|
12
|
-
}): Promise<void>;
|
|
13
|
-
getPresentChunks(attachmentID: string): Promise<Array<number>>;
|
|
14
|
-
stageChunk(params: {
|
|
15
|
-
attachmentID: string;
|
|
16
|
-
index: number;
|
|
17
|
-
offset: number;
|
|
18
|
-
bytes: Uint8Array;
|
|
19
|
-
}): Promise<void>;
|
|
20
|
-
completeFetch(attachmentID: string): Promise<void>;
|
|
21
|
-
};
|
|
5
|
+
export type BlobFetchAPI = Pick<BlobAPI, 'getAttachment' | 'beginFetch' | 'getPresentChunks' | 'stageChunk' | 'completeFetch'>;
|
|
22
6
|
export type PeerConnectionLister = {
|
|
23
7
|
list(): Array<PeerConnection>;
|
|
24
8
|
};
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { SchemaExtension } from '@kubun/engine';
|
|
2
2
|
import { type Logger } from '@kubun/logger';
|
|
3
|
+
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
3
4
|
import { type P2PEventEmitter } from './groups/events.js';
|
|
4
5
|
import type { SyncManager } from './sync/sync-manager.js';
|
|
5
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Recurring group catch-up control the GraphQL resolvers call, threaded from the
|
|
8
|
+
* plugin so they reach the same workflow-backed schedule the runtime API arms.
|
|
9
|
+
* Results are the workflow plugin's PeriodicSync projection (opaque here).
|
|
10
|
+
*/
|
|
11
|
+
export type GroupPeriodicSyncControl = {
|
|
12
|
+
enableGroupPeriodicSync(groupID: string, policy?: AdaptivePolicy): Promise<unknown>;
|
|
13
|
+
disableGroupPeriodicSync(groupID: string): Promise<unknown | null>;
|
|
14
|
+
getGroupPeriodicSync(groupID: string): Promise<unknown | null>;
|
|
15
|
+
};
|
|
16
|
+
/** Deploy-config the p2p schema extension reads. */
|
|
17
|
+
export type P2PSchemaConfig = {
|
|
18
|
+
periodicSync?: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function createP2PSchemaExtension(emitter: P2PEventEmitter, syncManager: SyncManager, logger?: Logger, control?: GroupPeriodicSyncControl, config?: P2PSchemaConfig): SchemaExtension;
|
package/lib/schema.js
CHANGED
|
@@ -760,9 +760,30 @@ extend type Subscription {
|
|
|
760
760
|
controlRequestSettled(groupID: ID, requestID: ID): ControlRequest!
|
|
761
761
|
}
|
|
762
762
|
`;
|
|
763
|
-
|
|
763
|
+
// Recurring-sync control fields, appended only when the deploy opted in. Gated
|
|
764
|
+
// because they reference `PeriodicSync`, defined by the co-deployed workflow
|
|
765
|
+
// plugin — referencing it on a p2p-only graph would fail schema build.
|
|
766
|
+
const periodicSyncSDL = `
|
|
767
|
+
extend type Query {
|
|
768
|
+
groupPeriodicSync(groupID: ID!): PeriodicSync
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
extend type Mutation {
|
|
772
|
+
enableGroupPeriodicSync(groupID: ID!, policy: JSON): PeriodicSync!
|
|
773
|
+
disableGroupPeriodicSync(groupID: ID!): PeriodicSync
|
|
774
|
+
}
|
|
775
|
+
`;
|
|
776
|
+
export function createP2PSchemaExtension(emitter, syncManager, logger = getKubunLogger('p2p-schema'), control, config) {
|
|
777
|
+
const periodicSyncEnabled = config?.periodicSync === true && control != null;
|
|
778
|
+
const periodicSyncQueryFields = periodicSyncEnabled ? {
|
|
779
|
+
groupPeriodicSync: (_source, args, _context)=>control.getGroupPeriodicSync(args.groupID)
|
|
780
|
+
} : {};
|
|
781
|
+
const periodicSyncMutationFields = periodicSyncEnabled ? {
|
|
782
|
+
enableGroupPeriodicSync: (_source, args, _context)=>control.enableGroupPeriodicSync(args.groupID, args.policy),
|
|
783
|
+
disableGroupPeriodicSync: (_source, args, _context)=>control.disableGroupPeriodicSync(args.groupID)
|
|
784
|
+
} : {};
|
|
764
785
|
return {
|
|
765
|
-
sdl,
|
|
786
|
+
sdl: periodicSyncEnabled ? sdl + periodicSyncSDL : sdl,
|
|
766
787
|
// These mutations perform network I/O (peer discovery, the MLS dance,
|
|
767
788
|
// merkle sync) and/or apply received mutations in their own per-step
|
|
768
789
|
// transactions. Running them inside `mutateGraph`'s write transaction would
|
|
@@ -789,10 +810,20 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
789
810
|
// Waits for its Add commit to land. The lane reads the group's handle on
|
|
790
811
|
// the registry's own connection, so holding a write transaction across
|
|
791
812
|
// that wait deadlocks single-connection SQLite.
|
|
792
|
-
'requestInviteToGroup'
|
|
813
|
+
'requestInviteToGroup',
|
|
814
|
+
// Write the workflow store synchronously and must return the resulting
|
|
815
|
+
// projection; running them inside `mutateGraph`'s write transaction would
|
|
816
|
+
// hold the single-connection DB across the workflow-store write and
|
|
817
|
+
// deadlock (as the connector's enable/disable are declared for the same
|
|
818
|
+
// reason).
|
|
819
|
+
...periodicSyncEnabled ? [
|
|
820
|
+
'enableGroupPeriodicSync',
|
|
821
|
+
'disableGroupPeriodicSync'
|
|
822
|
+
] : []
|
|
793
823
|
],
|
|
794
824
|
resolvers: {
|
|
795
825
|
queryFields: {
|
|
826
|
+
...periodicSyncQueryFields,
|
|
796
827
|
groups: async (_source, _args, context)=>{
|
|
797
828
|
return await requireP2P(context).group.list();
|
|
798
829
|
},
|
|
@@ -875,6 +906,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
875
906
|
}
|
|
876
907
|
},
|
|
877
908
|
mutationFields: {
|
|
909
|
+
...periodicSyncMutationFields,
|
|
878
910
|
connectPeer: async (_source, args, context)=>{
|
|
879
911
|
const peer = await requireP2P(context).peer.connect(args.url);
|
|
880
912
|
return {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { AdaptivePolicy, WorkflowOutcome } from '@kubun/plugin-workflow-api';
|
|
3
|
+
import type { P2PEventEmitter } from '../groups/events.js';
|
|
4
|
+
import type { PeerCatchUpData } from '../types.js';
|
|
5
|
+
/** Queue lane (and workflow name) for group catch-up sync. */
|
|
6
|
+
export declare const GROUP_SYNC_WORKFLOW = "group-sync";
|
|
7
|
+
/** How many group catch-up passes may run at once on this peer. */
|
|
8
|
+
export declare const GROUP_SYNC_CONCURRENCY = 4;
|
|
9
|
+
type HandlerContext = {
|
|
10
|
+
state: Record<string, unknown>;
|
|
11
|
+
params?: unknown;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
type HandlerResult = {
|
|
15
|
+
status: 'end';
|
|
16
|
+
state: Record<string, unknown>;
|
|
17
|
+
outcome?: WorkflowOutcome;
|
|
18
|
+
};
|
|
19
|
+
type GroupSyncHandler = (ctx: HandlerContext) => Promise<HandlerResult>;
|
|
20
|
+
export type GroupSyncWorkflowDefinition = {
|
|
21
|
+
name: string;
|
|
22
|
+
initialAction: {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
handlers: Record<string, GroupSyncHandler>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Map one catch-up pass to the outcome that paces the next run. A reachable peer
|
|
29
|
+
* that shipped mutations is productive (`changed`); a reachable peer with nothing
|
|
30
|
+
* new, or no scope to pull, is `idle`; no peer answered (none known, or none on)
|
|
31
|
+
* is `offline` so the scheduler backs off. A thrown apply never reaches here —
|
|
32
|
+
* the engine forces `error` on the failed terminal.
|
|
33
|
+
*/
|
|
34
|
+
export declare function classifyCatchUp(data: PeerCatchUpData): WorkflowOutcome;
|
|
35
|
+
export type GroupSyncWorkflowParams = {
|
|
36
|
+
catchUpWithBestPeer: (groupID: string) => Promise<PeerCatchUpData>;
|
|
37
|
+
logger: Logger;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Build the `group-sync` workflow definition. The single `start` handler runs one
|
|
41
|
+
* `catchUpWithBestPeer` pass and classifies its result; a genuine throw is left to
|
|
42
|
+
* propagate so the engine forces the terminal `error` (and its backoff), never
|
|
43
|
+
* swallowed into a false success.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createGroupSyncWorkflow(params: GroupSyncWorkflowParams): {
|
|
46
|
+
definition: GroupSyncWorkflowDefinition;
|
|
47
|
+
};
|
|
48
|
+
export type PeriodicSyncArmingAPI = {
|
|
49
|
+
scheduleAdaptive(name: string, params: unknown, opts: {
|
|
50
|
+
policy: AdaptivePolicy;
|
|
51
|
+
subjectKey: string;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
id: string;
|
|
54
|
+
}>;
|
|
55
|
+
getPeriodicSync(scheduleID: string): Promise<unknown | null>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Arm a group's recurring catch-up the first time it is joined — INSERT-IF-ABSENT.
|
|
59
|
+
* `scheduleAdaptive` is insert-OR-reactivate, so guarding on the existing
|
|
60
|
+
* projection is what stops a re-join or reboot resurrecting a schedule the user
|
|
61
|
+
* explicitly disabled. Deliberately the opposite of the explicit enable, which
|
|
62
|
+
* always (re)activates.
|
|
63
|
+
*/
|
|
64
|
+
export declare function armGroupPeriodicSyncIfAbsent(api: PeriodicSyncArmingAPI, groupID: string, policy: AdaptivePolicy): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Subscribe implicit arming to `groupJoined`. The listener is fire-and-forget and
|
|
67
|
+
* never throws into the emitter (`emit` rethrows listener failures, and this rides
|
|
68
|
+
* the group-create/join commit): its async work is detached and its failure logged.
|
|
69
|
+
* Returns the unsubscribe.
|
|
70
|
+
*/
|
|
71
|
+
export declare function wireGroupPeriodicSyncArming(params: {
|
|
72
|
+
emitter: P2PEventEmitter;
|
|
73
|
+
getWorkflowAPI: () => Promise<PeriodicSyncArmingAPI | undefined>;
|
|
74
|
+
policy: AdaptivePolicy;
|
|
75
|
+
logger: Logger;
|
|
76
|
+
}): () => void;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Queue lane (and workflow name) for group catch-up sync. */ export const GROUP_SYNC_WORKFLOW = 'group-sync';
|
|
2
|
+
/** How many group catch-up passes may run at once on this peer. */ export const GROUP_SYNC_CONCURRENCY = 4;
|
|
3
|
+
/**
|
|
4
|
+
* Map one catch-up pass to the outcome that paces the next run. A reachable peer
|
|
5
|
+
* that shipped mutations is productive (`changed`); a reachable peer with nothing
|
|
6
|
+
* new, or no scope to pull, is `idle`; no peer answered (none known, or none on)
|
|
7
|
+
* is `offline` so the scheduler backs off. A thrown apply never reaches here —
|
|
8
|
+
* the engine forces `error` on the failed terminal.
|
|
9
|
+
*/ export function classifyCatchUp(data) {
|
|
10
|
+
switch(data.outcome){
|
|
11
|
+
case 'synced':
|
|
12
|
+
return data.messagesReceived > 0 ? 'changed' : 'idle';
|
|
13
|
+
case 'no-scopes':
|
|
14
|
+
return 'idle';
|
|
15
|
+
case 'no-candidates':
|
|
16
|
+
case 'no-route':
|
|
17
|
+
return 'offline';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the `group-sync` workflow definition. The single `start` handler runs one
|
|
22
|
+
* `catchUpWithBestPeer` pass and classifies its result; a genuine throw is left to
|
|
23
|
+
* propagate so the engine forces the terminal `error` (and its backoff), never
|
|
24
|
+
* swallowed into a false success.
|
|
25
|
+
*/ export function createGroupSyncWorkflow(params) {
|
|
26
|
+
const { catchUpWithBestPeer, logger } = params;
|
|
27
|
+
const start = async (ctx)=>{
|
|
28
|
+
const { groupID } = ctx.params;
|
|
29
|
+
const data = await catchUpWithBestPeer(groupID);
|
|
30
|
+
const outcome = classifyCatchUp(data);
|
|
31
|
+
logger.debug('group catch-up classified', {
|
|
32
|
+
groupID,
|
|
33
|
+
outcome,
|
|
34
|
+
peerDID: data.peerDID
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
status: 'end',
|
|
38
|
+
state: {
|
|
39
|
+
groupID
|
|
40
|
+
},
|
|
41
|
+
outcome
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
definition: {
|
|
46
|
+
name: GROUP_SYNC_WORKFLOW,
|
|
47
|
+
initialAction: {
|
|
48
|
+
name: 'start'
|
|
49
|
+
},
|
|
50
|
+
handlers: {
|
|
51
|
+
start
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Arm a group's recurring catch-up the first time it is joined — INSERT-IF-ABSENT.
|
|
58
|
+
* `scheduleAdaptive` is insert-OR-reactivate, so guarding on the existing
|
|
59
|
+
* projection is what stops a re-join or reboot resurrecting a schedule the user
|
|
60
|
+
* explicitly disabled. Deliberately the opposite of the explicit enable, which
|
|
61
|
+
* always (re)activates.
|
|
62
|
+
*/ export async function armGroupPeriodicSyncIfAbsent(api, groupID, policy) {
|
|
63
|
+
const id = `${GROUP_SYNC_WORKFLOW}:${groupID}`;
|
|
64
|
+
if (await api.getPeriodicSync(id) == null) {
|
|
65
|
+
await api.scheduleAdaptive(GROUP_SYNC_WORKFLOW, {
|
|
66
|
+
groupID
|
|
67
|
+
}, {
|
|
68
|
+
policy,
|
|
69
|
+
subjectKey: groupID
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Subscribe implicit arming to `groupJoined`. The listener is fire-and-forget and
|
|
75
|
+
* never throws into the emitter (`emit` rethrows listener failures, and this rides
|
|
76
|
+
* the group-create/join commit): its async work is detached and its failure logged.
|
|
77
|
+
* Returns the unsubscribe.
|
|
78
|
+
*/ export function wireGroupPeriodicSyncArming(params) {
|
|
79
|
+
const { emitter, getWorkflowAPI, policy, logger } = params;
|
|
80
|
+
return emitter.on('groupJoined', (groupData)=>{
|
|
81
|
+
void (async ()=>{
|
|
82
|
+
try {
|
|
83
|
+
const api = await getWorkflowAPI();
|
|
84
|
+
if (api == null) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await armGroupPeriodicSyncIfAbsent(api, groupData.id, policy);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.error('implicit group periodic-sync arm failed', {
|
|
90
|
+
groupID: groupData.id,
|
|
91
|
+
error
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
});
|
|
96
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClientTransportOf } from '@enkaku/protocol';
|
|
2
|
-
import type {
|
|
2
|
+
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
3
3
|
import type { AccessLevel } from '@kubun/store-graph';
|
|
4
4
|
import type { ControlRequestKind, ControlRequestOutcome } from '@kubun/store-p2p';
|
|
5
5
|
import type { PeerAvailability, PeerCapability } from './groups/group-protocols.js';
|
|
@@ -1030,11 +1030,14 @@ export type SyncPluginAPI = {
|
|
|
1030
1030
|
delegationTokens?: Array<string>;
|
|
1031
1031
|
}): Promise<void>;
|
|
1032
1032
|
/**
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1033
|
+
* Recurring group catch-up control. The result is the workflow plugin's
|
|
1034
|
+
* PeriodicSync projection (opaque here); each needs the workflow API and throws
|
|
1035
|
+
* when it is absent (a local-only device has no scheduler). Subject is the
|
|
1036
|
+
* groupID — group writes are group-scoped and engine-signed, so no viewer.
|
|
1037
|
+
* `enableGroupPeriodicSync` always (re)activates; the implicit `groupJoined`
|
|
1038
|
+
* arming is insert-if-absent so it cannot resurrect a deliberately disabled one.
|
|
1038
1039
|
*/
|
|
1039
|
-
|
|
1040
|
+
enableGroupPeriodicSync(groupID: string, policy?: AdaptivePolicy): Promise<unknown>;
|
|
1041
|
+
disableGroupPeriodicSync(groupID: string): Promise<unknown | null>;
|
|
1042
|
+
getGroupPeriodicSync(groupID: string): Promise<unknown | null>;
|
|
1040
1043
|
};
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* `KubunErrorCode`, so `HandlerError.from` in `@enkaku/server` passes it through
|
|
4
4
|
* instead of flattening it to `EK01` / "Handler execution failed".
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* The `GraphQLError` case is graph-specific (its code lives in `extensions.code`
|
|
7
|
+
* and needs `graphql`), so it stays here; every other case delegates to the
|
|
8
|
+
* canonical, security-sensitive core in `@kubun/protocol`. That default — an
|
|
9
|
+
* error with no registered code is internal and stays opaque, since its message
|
|
10
|
+
* may carry DIDs, paths, or SQL — is defined in ONE place across both plugins.
|
|
10
11
|
*/
|
|
11
12
|
export declare function toHandlerError(cause: unknown): unknown;
|
|
12
13
|
/**
|
|
13
14
|
* Applies {@link toHandlerError} to every handler in a procedure record, so the
|
|
14
|
-
* translation lives in one place rather than in each handler.
|
|
15
|
+
* translation lives in one place rather than in each handler. Kept local because
|
|
16
|
+
* it must route through the GraphQL-aware {@link toHandlerError} above, not the
|
|
17
|
+
* plain protocol core.
|
|
15
18
|
*/
|
|
16
19
|
export declare function wrapHandlers(handlers: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import { HandlerError } from '@enkaku/server';
|
|
2
|
-
import { isKubunErrorCode } from '@kubun/protocol';
|
|
2
|
+
import { isKubunErrorCode, toHandlerError as protocolToHandlerError } from '@kubun/protocol';
|
|
3
3
|
import { GraphQLError } from 'graphql';
|
|
4
|
-
function isObject(value) {
|
|
5
|
-
return typeof value === 'object' && value != null && !Array.isArray(value);
|
|
6
|
-
}
|
|
7
4
|
/**
|
|
8
5
|
* Translates a thrown value into a `HandlerError` when it carries a registered
|
|
9
6
|
* `KubunErrorCode`, so `HandlerError.from` in `@enkaku/server` passes it through
|
|
10
7
|
* instead of flattening it to `EK01` / "Handler execution failed".
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The `GraphQLError` case is graph-specific (its code lives in `extensions.code`
|
|
10
|
+
* and needs `graphql`), so it stays here; every other case delegates to the
|
|
11
|
+
* canonical, security-sensitive core in `@kubun/protocol`. That default — an
|
|
12
|
+
* error with no registered code is internal and stays opaque, since its message
|
|
13
|
+
* may carry DIDs, paths, or SQL — is defined in ONE place across both plugins.
|
|
16
14
|
*/ export function toHandlerError(cause) {
|
|
17
|
-
if (cause instanceof HandlerError) {
|
|
18
|
-
return cause;
|
|
19
|
-
}
|
|
20
15
|
if (cause instanceof GraphQLError) {
|
|
21
16
|
const { code, ...data } = cause.extensions;
|
|
22
17
|
return isKubunErrorCode(code) ? new HandlerError({
|
|
@@ -26,21 +21,13 @@ function isObject(value) {
|
|
|
26
21
|
cause
|
|
27
22
|
}) : cause;
|
|
28
23
|
}
|
|
29
|
-
|
|
30
|
-
const message = typeof cause.message === 'string' ? cause.message : undefined;
|
|
31
|
-
const data = isObject(cause.data) ? cause.data : {};
|
|
32
|
-
return new HandlerError({
|
|
33
|
-
code: cause.code,
|
|
34
|
-
message,
|
|
35
|
-
data,
|
|
36
|
-
cause
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
return cause;
|
|
24
|
+
return protocolToHandlerError(cause);
|
|
40
25
|
}
|
|
41
26
|
/**
|
|
42
27
|
* Applies {@link toHandlerError} to every handler in a procedure record, so the
|
|
43
|
-
* translation lives in one place rather than in each handler.
|
|
28
|
+
* translation lives in one place rather than in each handler. Kept local because
|
|
29
|
+
* it must route through the GraphQL-aware {@link toHandlerError} above, not the
|
|
30
|
+
* plain protocol core.
|
|
44
31
|
*/ export function wrapHandlers(handlers) {
|
|
45
32
|
return Object.fromEntries(Object.entries(handlers).map(([name, handler])=>{
|
|
46
33
|
if (typeof handler !== 'function') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-p2p",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,27 @@
|
|
|
23
23
|
"@kokuin/capability": "^0.3.0",
|
|
24
24
|
"@kokuin/controller": "^0.1.0",
|
|
25
25
|
"@kokuin/token": "^0.5.0",
|
|
26
|
+
"@kubun/credential": "^0.14.0",
|
|
27
|
+
"@kubun/db": "^0.14.0",
|
|
28
|
+
"@kubun/db-adapter": "^0.14.0",
|
|
29
|
+
"@kubun/engine": "^0.14.0",
|
|
30
|
+
"@kubun/graphql": "^0.14.0",
|
|
31
|
+
"@kubun/hlc": "^0.14.0",
|
|
32
|
+
"@kubun/http-util": "^0.14.0",
|
|
33
|
+
"@kubun/id": "^0.14.0",
|
|
34
|
+
"@kubun/logger": "^0.14.0",
|
|
35
|
+
"@kubun/mutation": "^0.14.0",
|
|
36
|
+
"@kubun/plugin-blob-api": "^0.14.0",
|
|
37
|
+
"@kubun/plugin-http": "^0.14.0",
|
|
38
|
+
"@kubun/plugin-http-api": "^0.14.0",
|
|
39
|
+
"@kubun/plugin-workflow-api": "^0.14.0",
|
|
40
|
+
"@kubun/protocol": "^0.14.0",
|
|
41
|
+
"@kubun/store-blob": "^0.14.0",
|
|
42
|
+
"@kubun/store-controller": "^0.14.0",
|
|
43
|
+
"@kubun/store-credential": "^0.14.0",
|
|
44
|
+
"@kubun/store-delegation": "^0.14.0",
|
|
45
|
+
"@kubun/store-graph": "^0.14.0",
|
|
46
|
+
"@kubun/store-p2p": "^0.14.0",
|
|
26
47
|
"@kumiai/broadcast": "^0.7.0",
|
|
27
48
|
"@kumiai/hub-protocol": "^0.7.0",
|
|
28
49
|
"@kumiai/hub-server": "^0.7.0",
|
|
@@ -41,43 +62,26 @@
|
|
|
41
62
|
"@sozai/stream": "^0.2.0",
|
|
42
63
|
"graphql": "^16.14.2",
|
|
43
64
|
"kysely": "^0.29.5",
|
|
44
|
-
"ts-mls": "2.0.0-rc.13"
|
|
45
|
-
"@kubun/credential": "^0.13.0",
|
|
46
|
-
"@kubun/db-adapter": "^0.13.1",
|
|
47
|
-
"@kubun/db": "^0.13.0",
|
|
48
|
-
"@kubun/id": "^0.13.0",
|
|
49
|
-
"@kubun/graphql": "^0.13.1",
|
|
50
|
-
"@kubun/http-util": "^0.13.0",
|
|
51
|
-
"@kubun/mutation": "^0.13.0",
|
|
52
|
-
"@kubun/hlc": "^0.13.0",
|
|
53
|
-
"@kubun/plugin-http": "^0.13.2",
|
|
54
|
-
"@kubun/store-blob": "^0.13.0",
|
|
55
|
-
"@kubun/logger": "^0.13.0",
|
|
56
|
-
"@kubun/plugin-rpc": "^0.13.2",
|
|
57
|
-
"@kubun/store-controller": "^0.13.0",
|
|
58
|
-
"@kubun/engine": "^0.13.1",
|
|
59
|
-
"@kubun/store-p2p": "^0.13.0",
|
|
60
|
-
"@kubun/protocol": "^0.13.1",
|
|
61
|
-
"@kubun/store-credential": "^0.13.0",
|
|
62
|
-
"@kubun/store-delegation": "^0.13.0",
|
|
63
|
-
"@kubun/store-graph": "^0.13.2"
|
|
65
|
+
"ts-mls": "2.0.0-rc.13"
|
|
64
66
|
},
|
|
65
67
|
"devDependencies": {
|
|
68
|
+
"@kubun/blob-backend": "^0.14.0",
|
|
69
|
+
"@kubun/client": "^0.14.0",
|
|
70
|
+
"@kubun/db-better-sqlite": "^0.14.0",
|
|
71
|
+
"@kubun/db-node-sqlite": "^0.14.0",
|
|
72
|
+
"@kubun/db-postgres": "^0.14.0",
|
|
73
|
+
"@kubun/hub": "^0.14.0",
|
|
74
|
+
"@kubun/plugin-blob": "^0.14.0",
|
|
75
|
+
"@kubun/plugin-connector": "^0.14.0",
|
|
76
|
+
"@kubun/plugin-service-server": "^0.14.0",
|
|
77
|
+
"@kubun/plugin-workflow": "^0.14.0",
|
|
78
|
+
"@kubun/service-graph-api": "^0.14.0",
|
|
79
|
+
"@kubun/store-workflow": "^0.14.0",
|
|
80
|
+
"@kubun/test-utils": "^0.13.0",
|
|
66
81
|
"@kumiai/hub-conformance": "^0.7.0",
|
|
67
82
|
"@kumiai/rpc-conformance": "^0.7.0",
|
|
68
83
|
"@testcontainers/postgresql": "^12.1.0",
|
|
69
|
-
"get-port": "^7.2.0"
|
|
70
|
-
"@kubun/blob-backend": "^0.13.0",
|
|
71
|
-
"@kubun/client": "^0.13.0",
|
|
72
|
-
"@kubun/db-node-sqlite": "^0.13.0",
|
|
73
|
-
"@kubun/db-better-sqlite": "^0.13.0",
|
|
74
|
-
"@kubun/db-postgres": "^0.13.0",
|
|
75
|
-
"@kubun/hub": "^0.13.0",
|
|
76
|
-
"@kubun/plugin-blob": "^0.13.0",
|
|
77
|
-
"@kubun/plugin-connector": "^0.13.1",
|
|
78
|
-
"@kubun/test-utils": "^0.13.0",
|
|
79
|
-
"@kubun/plugin-workflow": "^0.13.0",
|
|
80
|
-
"@kubun/store-workflow": "^0.13.0"
|
|
84
|
+
"get-port": "^7.2.0"
|
|
81
85
|
},
|
|
82
86
|
"publishConfig": {
|
|
83
87
|
"access": "public"
|