@powerhousedao/reactor-api 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ type Query {
2
+ drives: [String!]!
3
+ driveIdBySlug(slug: String!): String
4
+ }
5
+
6
+ type Mutation {
7
+ addDrive(global: DocumentDriveStateInput!): DocumentDriveState
8
+ deleteDrive(id: ID!): Boolean
9
+ setDriveIcon(id: String!, icon: String!): Boolean
10
+ setDriveName(id: String!, name: String!): Boolean
11
+ }
12
+
13
+ input DocumentDriveStateInput {
14
+ name: String
15
+ id: String
16
+ slug: String
17
+ icon: String
18
+ }
@@ -0,0 +1,142 @@
1
+ type Query {
2
+ system: System
3
+ drive: DocumentDriveState
4
+ document(id: ID!): IDocument
5
+ documents: [String!]!
6
+ }
7
+
8
+ type Mutation {
9
+ registerPullResponderListener(filter: InputListenerFilter!): Listener
10
+ pushUpdates(strands: [InputStrandUpdate!]): [ListenerRevision!]!
11
+ acknowledge(listenerId: String!, revisions: [ListenerRevisionInput]): Boolean
12
+ }
13
+
14
+ input InputOperationSignerUser {
15
+ address: String!
16
+ networkId: String!
17
+ chainId: Int!
18
+ }
19
+
20
+ type OperationSignerUser {
21
+ address: String!
22
+ networkId: String!
23
+ chainId: Int!
24
+ }
25
+
26
+ input InputOperationSignerApp {
27
+ name: String!
28
+ key: String!
29
+ }
30
+
31
+ type OperationSignerApp {
32
+ name: String!
33
+ key: String!
34
+ }
35
+
36
+ input InputListenerFilter {
37
+ documentType: [String!]
38
+ documentId: [String!]
39
+ scope: [String!]
40
+ branch: [String!]
41
+ }
42
+
43
+ type OperationSigner {
44
+ app: OperationSignerApp
45
+ user: OperationSignerUser
46
+ signatures: [String!]!
47
+ }
48
+
49
+ input InputOperationSigner {
50
+ app: InputOperationSignerApp
51
+ user: InputOperationSignerUser
52
+ signatures: [String!]!
53
+ }
54
+
55
+ type OperationContext {
56
+ signer: OperationSigner
57
+ }
58
+
59
+ input InputOperationContext {
60
+ signer: InputOperationSigner
61
+ }
62
+
63
+ input InputOperationUpdate {
64
+ index: Int!
65
+ skip: Int
66
+ type: String!
67
+ id: String!
68
+ input: String!
69
+ hash: String!
70
+ timestamp: String!
71
+ error: String
72
+ context: InputOperationContext
73
+ }
74
+
75
+ type OperationUpdate {
76
+ index: Int!
77
+ skip: Int
78
+ type: String!
79
+ id: String!
80
+ input: String!
81
+ hash: String!
82
+ timestamp: String!
83
+ error: String
84
+ context: OperationContext
85
+ }
86
+
87
+ type StrandUpdate {
88
+ driveId: String!
89
+ documentId: String!
90
+ scope: String!
91
+ branch: String!
92
+ operations: [OperationUpdate!]!
93
+ }
94
+
95
+ input InputStrandUpdate {
96
+ driveId: String!
97
+ documentId: String!
98
+ scope: String!
99
+ branch: String!
100
+ operations: [InputOperationUpdate!]!
101
+ }
102
+
103
+ input ListenerFilterInput {
104
+ documentType: [String!]
105
+ documentId: [String!]
106
+ scope: [String!]
107
+ branch: [String!]
108
+ }
109
+
110
+ enum UpdateStatus {
111
+ SUCCESS
112
+ MISSING
113
+ CONFLICT
114
+ ERROR
115
+ }
116
+
117
+ input ListenerRevisionInput {
118
+ driveId: String!
119
+ documentId: String!
120
+ scope: String!
121
+ branch: String!
122
+ status: UpdateStatus!
123
+ revision: Int!
124
+ }
125
+
126
+ type ListenerRevision {
127
+ driveId: String!
128
+ documentId: String!
129
+ scope: String!
130
+ branch: String!
131
+ status: UpdateStatus!
132
+ revision: Int!
133
+ error: String
134
+ }
135
+
136
+ type System {
137
+ sync: Sync
138
+ }
139
+
140
+ type Sync {
141
+ strands(listenerId: ID!, since: String): [StrandUpdate!]!
142
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@powerhousedao/reactor-api",
3
+ "version": "1.2.0",
4
+ "description": "",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "keywords": [],
13
+ "author": "",
14
+ "license": "AGPL-3.0-only",
15
+ "devDependencies": {
16
+ "@types/body-parser": "^1.19.5",
17
+ "@types/cors": "^2.8.17",
18
+ "@types/express": "^5.0.0",
19
+ "document-drive": "1.2.0",
20
+ "esbuild": "^0.24.0",
21
+ "graphql": "^16.9.0",
22
+ "graphql-tag": "^2.12.6",
23
+ "tsup": "^8.3.0",
24
+ "document-model": "2.3.1"
25
+ },
26
+ "peerDependencies": {
27
+ "document-drive": "^1.0.1",
28
+ "document-model": "^2.2.0"
29
+ },
30
+ "dependencies": {
31
+ "@apollo/server": "^4.11.0",
32
+ "@apollo/subgraph": "^2.9.2",
33
+ "body-parser": "^1.20.3",
34
+ "cors": "^2.8.5",
35
+ "document-model-libs": "1.93.2",
36
+ "express": "^4.21.1"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "test": "vitest"
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./server";
2
+ export * from "./router";
3
+ export * from "./utils/create-schema";
@@ -0,0 +1,111 @@
1
+ import {
2
+ BaseDocumentDriveServer,
3
+ InternalTransmitter,
4
+ InternalTransmitterUpdate,
5
+ Listener,
6
+ } from "document-drive";
7
+ import { DocumentDriveDocument } from "document-model-libs/document-drive";
8
+
9
+ export type InternalListenerModule = {
10
+ name: string;
11
+ options: Omit<Listener, "driveId">;
12
+ transmit: (strands: InternalTransmitterUpdate[]) => Promise<void>;
13
+ };
14
+
15
+ export class InternalListenerManager {
16
+ private driveServer: BaseDocumentDriveServer;
17
+ private modules: InternalListenerModule[] = [];
18
+
19
+ constructor(driveServer: BaseDocumentDriveServer) {
20
+ this.driveServer = driveServer;
21
+ driveServer.on("driveAdded", this.#onDriveAdded.bind(this));
22
+ }
23
+
24
+ async #onDriveAdded(drive: DocumentDriveDocument) {
25
+ await Promise.all(
26
+ this.modules.map((module) =>
27
+ this.driveServer.addInternalListener(
28
+ drive.state.global.id,
29
+ {
30
+ transmit: (strands) => module.transmit(strands),
31
+ disconnect: async () => {
32
+ return Promise.resolve();
33
+ },
34
+ },
35
+ { ...module.options, label: module.options.label ?? "" }
36
+ )
37
+ )
38
+ );
39
+ }
40
+
41
+ async init() {
42
+ const drives = await this.driveServer.getDrives();
43
+
44
+ // eslint-disable-next-line no-restricted-syntax
45
+ for (const { options, transmit } of this.modules) {
46
+ console.log(options, transmit);
47
+ if (!options || !transmit) {
48
+ continue;
49
+ }
50
+
51
+ // eslint-disable-next-line no-restricted-syntax
52
+ for (const driveId of drives) {
53
+ try {
54
+ const { listenerId } = options;
55
+ const drive = await this.driveServer.getDrive(driveId);
56
+ const moduleRegistered =
57
+ drive.state.local.listeners.filter(
58
+ (l) => l.listenerId === listenerId
59
+ ).length > 0;
60
+ if (!moduleRegistered) {
61
+ await this.driveServer.addInternalListener(
62
+ driveId,
63
+ {
64
+ transmit: async (strands) => transmit(strands),
65
+ disconnect: async () => Promise.resolve(),
66
+ },
67
+ {
68
+ block: false,
69
+ filter: options.filter,
70
+ label: options.label!,
71
+ listenerId,
72
+ }
73
+ );
74
+
75
+ return;
76
+ }
77
+
78
+ const transmitter = await this.driveServer.getTransmitter(
79
+ driveId,
80
+ listenerId
81
+ );
82
+ if (transmitter instanceof InternalTransmitter) {
83
+ transmitter.setReceiver({
84
+ transmit: async (strands: InternalTransmitterUpdate[]) => {
85
+ await transmit(strands);
86
+ return Promise.resolve();
87
+ },
88
+ disconnect: () => {
89
+ console.log(`Disconnecting listener ${options.listenerId}`);
90
+ return Promise.resolve();
91
+ },
92
+ });
93
+ }
94
+ } catch (e) {
95
+ console.error(
96
+ `Error while initializing listener ${options.listenerId} for drive ${driveId}`,
97
+ e
98
+ );
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ async registerInternalListener(module: InternalListenerModule) {
105
+ if (this.modules.find((m) => m.name === module.name)) {
106
+ return;
107
+ }
108
+ this.modules.push(module);
109
+ await this.init();
110
+ }
111
+ }
package/src/router.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { ApolloServer } from "@apollo/server";
2
+ import { expressMiddleware } from "@apollo/server/express4";
3
+ import { ApolloServerPluginInlineTraceDisabled } from "@apollo/server/plugin/disabled";
4
+ import bodyParser from "body-parser";
5
+ import cors from "cors";
6
+ import { BaseDocumentDriveServer } from "document-drive";
7
+ import express, { IRouter, Router } from "express";
8
+ import {
9
+ InternalListenerManager,
10
+ InternalListenerModule,
11
+ } from "./internal-listener-manager";
12
+ import { SUBGRAPH_REGISTRY } from "./subgraphs";
13
+ import { Context } from "./types";
14
+ export let reactorRouter: IRouter = Router();
15
+
16
+ const getLocalSubgraphConfig = (subgraphName: string) =>
17
+ SUBGRAPH_REGISTRY.find((it) => it.name === subgraphName);
18
+
19
+ let listenerManager: InternalListenerManager | undefined;
20
+
21
+ export const getListenerManager = async (
22
+ driveServer: BaseDocumentDriveServer
23
+ ) => {
24
+ if (!listenerManager) {
25
+ listenerManager = new InternalListenerManager(driveServer);
26
+ await listenerManager.init();
27
+ }
28
+ return listenerManager;
29
+ };
30
+
31
+ export const updateRouter = async (driveServer: BaseDocumentDriveServer) => {
32
+ const newRouter = Router();
33
+ newRouter.use(cors());
34
+ newRouter.use(bodyParser.json());
35
+ // Run each subgraph on the same http server, but at different paths
36
+ for (const subgraph of SUBGRAPH_REGISTRY) {
37
+ const subgraphConfig = getLocalSubgraphConfig(subgraph.name);
38
+ if (!subgraphConfig) continue;
39
+
40
+ // get schema
41
+ const schema = subgraphConfig.getSchema(driveServer);
42
+
43
+ // create apollo server
44
+ const server = new ApolloServer({
45
+ schema,
46
+ introspection: true,
47
+ plugins: [ApolloServerPluginInlineTraceDisabled()],
48
+ });
49
+
50
+ // start apollo server
51
+ await server.start();
52
+
53
+ // setup path
54
+ const path = `/${subgraphConfig.name}`;
55
+ newRouter.use(
56
+ path,
57
+ expressMiddleware(server, {
58
+ context: ({ req }): Promise<Context> =>
59
+ Promise.resolve({
60
+ headers: req.headers,
61
+ driveId: req.params.drive ?? undefined,
62
+ driveServer,
63
+ ...getAdditionalContextFields(),
64
+ }),
65
+ })
66
+ );
67
+ console.log(`Setting up [${subgraphConfig.name}] subgraph at ${path}`);
68
+ }
69
+
70
+ listenerManager = await getListenerManager(driveServer);
71
+ reactorRouter = newRouter;
72
+ console.log("All subgraphs started.");
73
+ };
74
+
75
+ let docDriveServer: BaseDocumentDriveServer;
76
+ export const initReactorRouter = async (
77
+ path: string,
78
+ app: express.Express,
79
+ driveServer: BaseDocumentDriveServer
80
+ ) => {
81
+ docDriveServer = driveServer;
82
+ const models = driveServer.getDocumentModels();
83
+ const driveModel = models.find(
84
+ (it) => it.documentModel.name === "DocumentDrive"
85
+ );
86
+
87
+ if (!driveModel) {
88
+ throw new Error("DocumentDrive model required");
89
+ }
90
+
91
+ await updateRouter(driveServer);
92
+ driveServer.on("documentModels", () => {
93
+ updateRouter(driveServer);
94
+ });
95
+
96
+ app.use(path, (req, res, next) => reactorRouter(req, res, next));
97
+ };
98
+
99
+ export const addSubgraph = async (
100
+ subgraph: (typeof SUBGRAPH_REGISTRY)[number]
101
+ ) => {
102
+ SUBGRAPH_REGISTRY.unshift(subgraph);
103
+ await updateRouter(docDriveServer);
104
+ };
105
+
106
+ export const registerInternalListener = async (
107
+ module: InternalListenerModule
108
+ ) => {
109
+ if (!listenerManager) {
110
+ throw new Error("Listener manager not initialized");
111
+ }
112
+ await listenerManager.registerInternalListener(module);
113
+ };
114
+
115
+ let contextFields = {};
116
+ export const getAdditionalContextFields = () => {
117
+ return contextFields;
118
+ };
119
+
120
+ export const setAdditionalContextFields = (fields: Record<string, any>) => {
121
+ contextFields = { ...contextFields, ...fields };
122
+ };
package/src/server.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { BaseDocumentDriveServer } from "document-drive";
2
+ import express, { Express } from "express";
3
+ import { initReactorRouter } from "./router";
4
+ type Options = {
5
+ express?: Express;
6
+ port?: number;
7
+ };
8
+
9
+ const DEFAULT_PORT = 4000;
10
+
11
+ export async function startAPI(
12
+ reactor: BaseDocumentDriveServer,
13
+ options: Options
14
+ ) {
15
+ const port = options.port ?? DEFAULT_PORT;
16
+ const app = options.express ?? express();
17
+
18
+ await initReactorRouter("/", app, reactor);
19
+
20
+ app.listen(port);
21
+ }
@@ -0,0 +1,223 @@
1
+ import {
2
+ generateUUID,
3
+ ListenerRevision,
4
+ PullResponderTransmitter,
5
+ StrandUpdateGraphQL,
6
+ } from "document-drive";
7
+ import {
8
+ actions,
9
+ DocumentDriveAction,
10
+ Listener,
11
+ ListenerFilter,
12
+ TransmitterType,
13
+ } from "document-model-libs/document-drive";
14
+ import { BaseAction, Operation } from "document-model/document";
15
+ import {
16
+ DocumentModelInput,
17
+ DocumentModelState,
18
+ } from "document-model/document-model";
19
+ import { Context } from "../../../../../../apps/switchboard/types";
20
+
21
+ export const resolvers = {
22
+ Query: {
23
+ drive: async (_: unknown, args: unknown, ctx: Context) => {
24
+ if (!ctx.driveId) throw new Error("Drive ID is required");
25
+ const drive = await ctx.driveServer.getDrive(ctx.driveId);
26
+ return drive.state.global;
27
+ },
28
+ documents: async (_: unknown, args: unknown, ctx: Context) => {
29
+ if (!ctx.driveId) throw new Error("Drive ID is required");
30
+ const documents = await ctx.driveServer.getDocuments(ctx.driveId);
31
+ return documents;
32
+ },
33
+ document: async (_: unknown, { id }: { id: string }, ctx: Context) => {
34
+ if (!ctx.driveId) throw new Error("Drive ID is required");
35
+ const document = await ctx.driveServer.getDocument(ctx.driveId, id);
36
+
37
+ const dms = ctx.driveServer.getDocumentModels();
38
+ const dm = dms.find(
39
+ ({ documentModel }: { documentModel: DocumentModelState }) =>
40
+ documentModel.id === document.documentType
41
+ );
42
+ const globalState = document.state.global;
43
+ if (!globalState) throw new Error("Document not found");
44
+ const response = {
45
+ ...document,
46
+ id,
47
+ revision: document.revision.global,
48
+ state: document.state.global,
49
+ operations: document.operations.global.map((op: Operation) => ({
50
+ ...op,
51
+ inputText:
52
+ typeof op.input === "string" ? op.input : JSON.stringify(op.input),
53
+ })),
54
+ initialState: document.initialState.state.global,
55
+ __typename: dm?.documentModel.name,
56
+ };
57
+ console.log(response);
58
+ return response;
59
+ },
60
+ system: () => ({ sync: {} }),
61
+ },
62
+ Mutation: {
63
+ registerPullResponderListener: async (
64
+ _: unknown,
65
+ { filter }: { filter: ListenerFilter },
66
+ ctx: Context
67
+ ) => {
68
+ if (!ctx.driveId) throw new Error("Drive ID is required");
69
+ const uuid = generateUUID();
70
+ const listener: Listener = {
71
+ block: false,
72
+ callInfo: {
73
+ data: "",
74
+ name: "PullResponder",
75
+ transmitterType: "PullResponder" as TransmitterType,
76
+ },
77
+ filter: {
78
+ branch: filter.branch ?? [],
79
+ documentId: filter.documentId ?? [],
80
+ documentType: filter.documentType ?? [],
81
+ scope: filter.scope ?? [],
82
+ },
83
+ label: `Pullresponder #${uuid}`,
84
+ listenerId: uuid,
85
+ system: false,
86
+ };
87
+
88
+ const result = await ctx.driveServer.queueDriveAction(
89
+ ctx.driveId,
90
+ actions.addListener({ listener })
91
+ );
92
+
93
+ console.log(result);
94
+ if (result.status !== "SUCCESS" && result.error) {
95
+ throw new Error(
96
+ `Listener couldn't be registered: ${result.error.message}`
97
+ );
98
+ }
99
+
100
+ return listener;
101
+ },
102
+ pushUpdates: async (
103
+ _: unknown,
104
+ { strands }: { strands: StrandUpdateGraphQL[] },
105
+ ctx: Context
106
+ ) => {
107
+ if (!ctx.driveId) throw new Error("Drive ID is required");
108
+ const listenerRevisions: ListenerRevision[] = await Promise.all(
109
+ strands.map(async (s) => {
110
+ const operations =
111
+ s.operations.map((o) => ({
112
+ ...o,
113
+ input: JSON.parse(o.input) as DocumentModelInput,
114
+ skip: o.skip ?? 0,
115
+ scope: s.scope,
116
+ branch: "main",
117
+ })) ?? [];
118
+
119
+ const result = await (s.documentId !== undefined
120
+ ? ctx.driveServer.queueOperations(
121
+ s.driveId,
122
+ s.documentId,
123
+ operations
124
+ )
125
+ : ctx.driveServer.queueDriveOperations(
126
+ s.driveId,
127
+ operations as Operation<DocumentDriveAction | BaseAction>[]
128
+ ));
129
+
130
+ const scopeOperations = result.document?.operations[s.scope] ?? [];
131
+ if (scopeOperations.length === 0) {
132
+ return {
133
+ revision: -1,
134
+ branch: s.branch,
135
+ documentId: s.documentId ?? "",
136
+ driveId: s.driveId,
137
+ scope: s.scope,
138
+ status: result.status,
139
+ };
140
+ }
141
+
142
+ const revision = scopeOperations.slice().pop()?.index ?? -1;
143
+ return {
144
+ revision,
145
+ branch: s.branch,
146
+ documentId: s.documentId ?? "",
147
+ driveId: s.driveId,
148
+ scope: s.scope,
149
+ status: result.status,
150
+ error: result.error?.message || undefined,
151
+ };
152
+ })
153
+ );
154
+
155
+ return listenerRevisions;
156
+ },
157
+ acknowledge: async (
158
+ _: unknown,
159
+ {
160
+ listenerId,
161
+ revisions,
162
+ }: { listenerId: string; revisions: ListenerRevision[] },
163
+ ctx: Context
164
+ ) => {
165
+ if (!listenerId || !revisions) return false;
166
+ if (!ctx.driveId) throw new Error("Drive ID is required");
167
+ const validEntries = revisions
168
+ .filter((r) => r !== null)
169
+ .map((e) => ({
170
+ driveId: e.driveId,
171
+ documentId: e.documentId,
172
+ scope: e.scope,
173
+ branch: e.branch,
174
+ revision: e.revision,
175
+ status: e.status,
176
+ }));
177
+
178
+ const transmitter = (await ctx.driveServer.getTransmitter(
179
+ ctx.driveId,
180
+ listenerId
181
+ )) as PullResponderTransmitter;
182
+ const result = await transmitter.processAcknowledge(
183
+ ctx.driveId ?? "1",
184
+ listenerId,
185
+ validEntries
186
+ );
187
+
188
+ return result;
189
+ },
190
+ },
191
+ System: {},
192
+ Sync: {
193
+ strands: async (
194
+ _: unknown,
195
+ { listenerId, since }: { listenerId: string; since: string | undefined },
196
+ ctx: Context
197
+ ) => {
198
+ if (!ctx.driveId) throw new Error("Drive ID is required");
199
+ const listener = (await ctx.driveServer.getTransmitter(
200
+ ctx.driveId,
201
+ listenerId
202
+ )) as PullResponderTransmitter;
203
+ const strands = await listener.getStrands({ since });
204
+ return strands.map((e) => ({
205
+ driveId: e.driveId,
206
+ documentId: e.documentId,
207
+ scope: e.scope,
208
+ branch: e.branch,
209
+ operations: e.operations.map((o) => ({
210
+ index: o.index,
211
+ skip: o.skip,
212
+ name: o.type,
213
+ input: JSON.stringify(o.input),
214
+ hash: o.hash,
215
+ timestamp: o.timestamp,
216
+ type: o.type,
217
+ context: o.context,
218
+ id: o.id,
219
+ })),
220
+ }));
221
+ },
222
+ },
223
+ };