@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 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 plugin-rpc's graph server.
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
- fetchControllerLog: (did)=>fetchControllerLog({
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: (_config)=>createP2PSchemaExtension(emitter, syncManager, params.getLogger('p2p-schema')),
626
+ schemaExtension: (config)=>createP2PSchemaExtension(emitter, syncManager, params.getLogger('p2p-schema'), groupPeriodicSyncControl, config),
532
627
  api: pluginAPI,
533
628
  createContextFactory: ()=>{
534
629
  return (ctx, stores)=>{
@@ -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
- export declare function createP2PSchemaExtension(emitter: P2PEventEmitter, syncManager: SyncManager, logger?: Logger): SchemaExtension;
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
- export function createP2PSchemaExtension(emitter, syncManager, logger = getKubunLogger('p2p-schema')) {
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 { SignedEvent } from '@kokuin/controller';
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
- * Pull a `did:kokuin:` controller's key-event log from a connected group peer
1034
- * that holds it, over `controller/get-log`. The engine calls this on a
1035
- * verify-time controller-store miss; the events self-authenticate, so the
1036
- * caller's resolver folds + verifies them. `undefined` when no connected peer
1037
- * answers with a non-empty log.
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
- fetchControllerLog(did: string): Promise<Array<SignedEvent> | undefined>;
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
- * Anything else is returned unchanged, and that default is the security
7
- * property: an error with no registered code is internal, and its message may
8
- * carry DIDs, paths, or SQL. Forwarding a refusal is safe only because the
9
- * throw site attached a code the registry allows to leave the device.
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
- * Anything else is returned unchanged, and that default is the security
13
- * property: an error with no registered code is internal, and its message may
14
- * carry DIDs, paths, or SQL. Forwarding a refusal is safe only because the
15
- * throw site attached a code the registry allows to leave the device.
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
- if (isObject(cause) && isKubunErrorCode(cause.code)) {
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.13.1",
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"