@graphprotocol/hypergraph 0.4.1 → 0.5.0-alpha

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.
Files changed (52) hide show
  1. package/dist/cli/Cli.js +1 -1
  2. package/dist/cli/bin.js +0 -0
  3. package/dist/cli/bin.js.map +1 -1
  4. package/dist/cli/bun.js +0 -0
  5. package/dist/cli/services/Model.d.ts +99 -0
  6. package/dist/cli/services/Model.d.ts.map +1 -0
  7. package/dist/cli/services/Model.js +52 -0
  8. package/dist/cli/services/Model.js.map +1 -0
  9. package/dist/cli/services/Typesync.d.ts +7 -4
  10. package/dist/cli/services/Typesync.d.ts.map +1 -1
  11. package/dist/cli/services/Typesync.js +106 -4
  12. package/dist/cli/services/Typesync.js.map +1 -1
  13. package/dist/cli/services/Utils.d.ts +81 -0
  14. package/dist/cli/services/Utils.d.ts.map +1 -1
  15. package/dist/cli/services/Utils.js +198 -8
  16. package/dist/cli/services/Utils.js.map +1 -1
  17. package/dist/cli/subcommands/typesync.d.ts +13 -2
  18. package/dist/cli/subcommands/typesync.d.ts.map +1 -1
  19. package/dist/cli/subcommands/typesync.js +141 -21
  20. package/dist/cli/subcommands/typesync.js.map +1 -1
  21. package/dist/entity/findMany.d.ts.map +1 -1
  22. package/dist/entity/findMany.js +21 -13
  23. package/dist/entity/findMany.js.map +1 -1
  24. package/dist/entity/types.d.ts +0 -7
  25. package/dist/entity/types.d.ts.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/mapping/Mapping.d.ts +24 -12
  31. package/dist/mapping/Mapping.d.ts.map +1 -1
  32. package/dist/mapping/Mapping.js +12 -4
  33. package/dist/mapping/Mapping.js.map +1 -1
  34. package/dist/typesync-studio/dist/assets/authenticate-callback-XTxFqKgn.js +1 -0
  35. package/dist/typesync-studio/dist/assets/ccip-_s3urR1L.js +1 -0
  36. package/dist/typesync-studio/dist/assets/index-B-tctDXW.js +88 -0
  37. package/dist/typesync-studio/dist/assets/index-BHBkzpXd.css +1 -0
  38. package/dist/typesync-studio/dist/assets/index-bioTPE3q.js +215 -0
  39. package/dist/typesync-studio/dist/index.html +30 -0
  40. package/dist/typesync-studio/dist/manifest.json +20 -0
  41. package/dist/typesync-studio/dist/robots.txt +3 -0
  42. package/package.json +9 -8
  43. package/src/cli/Cli.ts +1 -1
  44. package/src/cli/bin.ts +0 -1
  45. package/src/cli/services/Model.ts +87 -0
  46. package/src/cli/services/Typesync.ts +137 -9
  47. package/src/cli/services/Utils.ts +231 -11
  48. package/src/cli/subcommands/typesync.ts +251 -42
  49. package/src/entity/findMany.ts +27 -15
  50. package/src/entity/types.ts +0 -7
  51. package/src/index.ts +1 -0
  52. package/src/mapping/Mapping.ts +21 -6
@@ -1,8 +1,10 @@
1
+ import { Doc } from '@effect/printer';
1
2
  import { Data, Effect, Array as EffectArray } from 'effect';
2
3
  import ts from 'typescript';
3
4
 
4
5
  import * as Mapping from '../../mapping/Mapping.js';
5
6
  import * as Utils from '../../mapping/Utils.js';
7
+ import type * as Model from './Model.js';
6
8
 
7
9
  /**
8
10
  * Takes a parsed schema.ts file and maps it to a the Mapping.Schema type.
@@ -16,17 +18,17 @@ import * as Utils from '../../mapping/Utils.js';
16
18
  export const parseSchema = (
17
19
  sourceCode: string,
18
20
  mapping: Mapping.Mapping,
19
- ): Effect.Effect<Mapping.Schema, SchemaParserFailure> =>
21
+ ): Effect.Effect<Model.TypesyncHypergraphSchema, SchemaParserFailure> =>
20
22
  Effect.try({
21
23
  try() {
22
24
  const sourceFile = ts.createSourceFile('schema.ts', sourceCode, ts.ScriptTarget.Latest, true);
23
25
 
24
- const entities: Array<Mapping.SchemaType> = [];
26
+ const entities: Array<Model.TypesyncHypergraphSchemaType> = [];
25
27
 
26
28
  const visit = (node: ts.Node) => {
27
29
  if (ts.isClassDeclaration(node) && node.name) {
28
30
  const className = node.name.text;
29
- const properties: Array<Mapping.SchemaTypePropertyPrimitive | Mapping.SchemaTypePropertyRelation> = [];
31
+ const properties: Array<Model.TypesyncHypergraphSchemaTypeProperty> = [];
30
32
 
31
33
  // Find the Entity.Class call
32
34
  if (node.heritageClauses) {
@@ -44,14 +46,29 @@ export const parseSchema = (
44
46
  const propName = prop.name.text;
45
47
  let dataType: Mapping.SchemaDataType = 'String';
46
48
  let relationType: string | undefined;
49
+ let isOptional = false;
47
50
 
48
51
  const mappingEntry = mapping[className];
49
52
  const camelCasePropName = Utils.toCamelCase(propName);
50
53
 
54
+ // Check if the property is wrapped with Type.optional()
55
+ let typeExpression = prop.initializer;
56
+ if (
57
+ ts.isCallExpression(typeExpression) &&
58
+ ts.isPropertyAccessExpression(typeExpression.expression) &&
59
+ typeExpression.expression.name.text === 'optional'
60
+ ) {
61
+ isOptional = true;
62
+ // Unwrap the optional to get the actual type
63
+ if (typeExpression.arguments.length > 0) {
64
+ typeExpression = typeExpression.arguments[0];
65
+ }
66
+ }
67
+
51
68
  // Extract the type
52
- if (ts.isPropertyAccessExpression(prop.initializer)) {
53
- // Simple types like Type.Text
54
- dataType = Mapping.getDataType(prop.initializer.name.text);
69
+ if (ts.isPropertyAccessExpression(typeExpression)) {
70
+ // Simple types like Type.String
71
+ dataType = Mapping.getDataType(typeExpression.name.text);
55
72
 
56
73
  // Look up the property's knowledgeGraphId from the mapping
57
74
  const propKnowledgeGraphId = mappingEntry?.properties?.[camelCasePropName] || null;
@@ -61,10 +78,12 @@ export const parseSchema = (
61
78
  name: propName,
62
79
  dataType: dataType as Mapping.SchemaDataTypePrimitive,
63
80
  knowledgeGraphId: propKnowledgeGraphId,
64
- } satisfies Mapping.SchemaTypePropertyPrimitive);
65
- } else if (ts.isCallExpression(prop.initializer)) {
81
+ optional: isOptional || undefined,
82
+ status: propKnowledgeGraphId != null ? 'published' : 'synced',
83
+ } satisfies Model.TypesyncHypergraphSchemaTypeProperty);
84
+ } else if (ts.isCallExpression(typeExpression)) {
66
85
  // Relation types like Type.Relation(User)
67
- const callNode = prop.initializer;
86
+ const callNode = typeExpression;
68
87
  if (ts.isPropertyAccessExpression(callNode.expression)) {
69
88
  const typeName = callNode.expression.name.text;
70
89
 
@@ -83,7 +102,9 @@ export const parseSchema = (
83
102
  dataType,
84
103
  relationType,
85
104
  knowledgeGraphId: relKnowledgeGraphId,
86
- } satisfies Mapping.SchemaTypePropertyRelation);
105
+ optional: isOptional || undefined,
106
+ status: relKnowledgeGraphId != null ? 'published' : 'synced',
107
+ } satisfies Model.TypesyncHypergraphSchemaTypeProperty);
87
108
  }
88
109
  }
89
110
  }
@@ -101,7 +122,12 @@ export const parseSchema = (
101
122
  const mappingEntry = mapping[Utils.toPascalCase(className)];
102
123
  const typeKnowledgeGraphId = mappingEntry?.typeIds?.[0] ? mappingEntry.typeIds[0] : null;
103
124
 
104
- entities.push({ name: className, knowledgeGraphId: typeKnowledgeGraphId, properties });
125
+ entities.push({
126
+ name: className,
127
+ knowledgeGraphId: typeKnowledgeGraphId,
128
+ properties,
129
+ status: typeKnowledgeGraphId != null ? 'published' : 'synced',
130
+ });
105
131
  }
106
132
 
107
133
  ts.forEachChild(node, visit);
@@ -185,3 +211,197 @@ export function parseHypergraphMapping(moduleExport: any): Mapping.Mapping {
185
211
  // If no preferred names found, use the first one
186
212
  return mappingCandidates[0][1] as Mapping.Mapping;
187
213
  }
214
+
215
+ function fieldToEntityString({ name, dataType, optional = false }: Model.TypesyncHypergraphSchemaTypeProperty): string {
216
+ // Convert type to Entity type
217
+ const entityType = (() => {
218
+ switch (true) {
219
+ case dataType === 'String':
220
+ return 'Type.String';
221
+ case dataType === 'Number':
222
+ return 'Type.Number';
223
+ case dataType === 'Boolean':
224
+ return 'Type.Boolean';
225
+ case dataType === 'Date':
226
+ return 'Type.Date';
227
+ case dataType === 'Point':
228
+ return 'Type.Point';
229
+ case Mapping.isDataTypeRelation(dataType):
230
+ // renders the type as `Type.Relation(Entity)`
231
+ return `Type.${dataType}`;
232
+ default:
233
+ // how to handle complex types
234
+ return 'Type.String';
235
+ }
236
+ })();
237
+
238
+ if (optional === true) {
239
+ return ` ${Utils.toCamelCase(name)}: Type.optional(${entityType})`;
240
+ }
241
+
242
+ // adds a tab before the property
243
+ return ` ${Utils.toCamelCase(name)}: ${entityType}`;
244
+ }
245
+
246
+ function typeDefinitionToString(type: Model.TypesyncHypergraphSchemaType): string | null {
247
+ if (!type.name) {
248
+ return null;
249
+ }
250
+ const fields = type.properties.filter((_prop) => _prop.name != null && _prop.name.length > 0);
251
+ if (fields.length === 0) {
252
+ return null;
253
+ }
254
+
255
+ const fieldStrings = fields.map(fieldToEntityString);
256
+
257
+ const name = Utils.toPascalCase(type.name);
258
+ return `export class ${name} extends Entity.Class<${name}>('${name}')({
259
+ ${fieldStrings.join(',\n')}
260
+ }) {}`;
261
+ }
262
+
263
+ /**
264
+ * Builds a string of the schema.ts file contents after parsing the schema into the correct format.
265
+ *
266
+ * @example
267
+ *
268
+ * ```typescript
269
+ * const schema = Model.TypesyncHypergraphSchema.make({
270
+ * types: [
271
+ * {
272
+ * name: "User",
273
+ * knowledgeGraphId: null,
274
+ * status: null,
275
+ * properties: [
276
+ * {
277
+ * name: "name",
278
+ * dataType: "String",
279
+ * knowledgeGraphId: null,
280
+ * optional: null,
281
+ * status: null
282
+ * }
283
+ * ]
284
+ * }
285
+ * ]
286
+ * })
287
+ * const schemaFile = buildSchemaFile(schema)
288
+ *
289
+ * expect(schemaFile).toEqual(`
290
+ * import { Entity, Type } from '@graphprotocol/hypergraph';
291
+ *
292
+ * export class User extends Entity.Class<User>('User')({
293
+ * name: Type.String
294
+ * }) {}
295
+ * `)
296
+ * ```
297
+ */
298
+ export function buildSchemaFile(schema: Model.TypesyncHypergraphSchema) {
299
+ const importStatement = `import { Entity, Type } from '@graphprotocol/hypergraph';`;
300
+
301
+ const typeDefinitions = schema.types
302
+ .map(typeDefinitionToString)
303
+ .filter((def) => def != null)
304
+ .join('\n\n');
305
+ return [importStatement, typeDefinitions].join('\n\n');
306
+ }
307
+
308
+ export function buildMappingFile(mapping: Mapping.Mapping | Model.TypesyncHypergraphMapping) {
309
+ // Import statements
310
+ const imports = Doc.vsep([
311
+ Doc.text("import type { Mapping } from '@graphprotocol/hypergraph/mapping';"),
312
+ Doc.text("import { Id } from '@graphprotocol/hypergraph';"),
313
+ ]);
314
+
315
+ // Generate the mapping object - build it line by line for exact formatting
316
+ const mappingLines = [Doc.text('export const mapping: Mapping = {')];
317
+
318
+ for (const [typeName, typeData] of Object.entries(mapping)) {
319
+ mappingLines.push(Doc.text(` ${typeName}: {`));
320
+
321
+ // Type IDs
322
+ const typeIdsList = typeData.typeIds.map((id: string) => `Id("${id}")`).join(', ');
323
+ mappingLines.push(Doc.text(` typeIds: [${typeIdsList}],`));
324
+
325
+ // Properties
326
+ const properties = Object.entries(typeData.properties ?? {});
327
+ if (EffectArray.isNonEmptyArray(properties)) {
328
+ mappingLines.push(Doc.text(' properties: {'));
329
+ properties.forEach(([propName, propId], index, entries) => {
330
+ const isLast = index === entries.length - 1;
331
+ const comma = isLast ? '' : ',';
332
+ mappingLines.push(Doc.text(` ${propName}: Id("${propId}")${comma}`));
333
+ });
334
+ mappingLines.push(Doc.text(' },'));
335
+ }
336
+
337
+ // Relations
338
+ const relations = Object.entries(typeData.relations ?? {});
339
+ if (EffectArray.isNonEmptyArray(relations)) {
340
+ mappingLines.push(Doc.text(' relations: {'));
341
+ relations.forEach(([relationName, relationId], index, entries) => {
342
+ const isLast = index === entries.length - 1;
343
+ const comma = isLast ? '' : ',';
344
+ mappingLines.push(Doc.text(` ${relationName}: Id("${relationId}")${comma}`));
345
+ });
346
+ mappingLines.push(Doc.text(' },'));
347
+ }
348
+
349
+ mappingLines.push(Doc.text(' },'));
350
+ }
351
+
352
+ mappingLines.push(Doc.rbrace);
353
+
354
+ const compiled = Doc.vcat([imports, Doc.empty, ...mappingLines]);
355
+
356
+ return Doc.render(compiled, {
357
+ style: 'pretty',
358
+ options: { lineWidth: 120 },
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Builds a string of the mapping.ts file contents after parsing the schema into the correct mapping format.
364
+ *
365
+ * @example
366
+ *
367
+ * ```typescript
368
+ * const schema = Model.TypesyncHypergraphSchema.make({
369
+ * types: [
370
+ * {
371
+ * name: "User",
372
+ * knowledgeGraphId: "7f9562d4-034d-4385-bf5c-f02cdebba47a",
373
+ * status: null,
374
+ * properties: [
375
+ * {
376
+ * name: "name",
377
+ * dataType: "String",
378
+ * knowledgeGraphId: "a126ca53-0c8e-48d5-b888-82c734c38935",
379
+ * optional: null,
380
+ * status: null
381
+ * }
382
+ * ]
383
+ * }
384
+ * ]
385
+ * })
386
+ * const mappingFile = buildMappingFile(schema)
387
+ *
388
+ * expect(mappingFile).toEqual(`
389
+ * import type { Mapping } from '@graphprotocol/hypergraph/mapping';
390
+ * import { Id } from '@graphprotocol/hypergraph';
391
+ *
392
+ * export const mapping: Mapping = {
393
+ * User: {
394
+ * typeIds: [Id('7f9562d4-034d-4385-bf5c-f02cdebba47a')],
395
+ * properties: {
396
+ * name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'),
397
+ * }
398
+ * }
399
+ * }
400
+ * `)
401
+ * ```
402
+ */
403
+ export function buildMappingFileFromSchema(schema: Model.TypesyncHypergraphSchema) {
404
+ const [mapping] = Mapping.generateMapping(schema);
405
+
406
+ return buildMappingFile(mapping);
407
+ }
@@ -1,6 +1,8 @@
1
1
  import { createServer } from 'node:http';
2
+ import { fileURLToPath } from 'node:url';
2
3
  import { Command, Options } from '@effect/cli';
3
4
  import {
5
+ FileSystem,
4
6
  HttpApi,
5
7
  HttpApiBuilder,
6
8
  HttpApiEndpoint,
@@ -8,70 +10,258 @@ import {
8
10
  HttpApiGroup,
9
11
  HttpApiSchema,
10
12
  HttpMiddleware,
13
+ HttpRouter,
11
14
  HttpServer,
12
15
  HttpServerResponse,
16
+ Path,
13
17
  } from '@effect/platform';
14
18
  import { NodeHttpServer } from '@effect/platform-node';
15
19
  import { AnsiDoc } from '@effect/printer-ansi';
16
- import { Effect, Layer, Schema } from 'effect';
20
+ import { Cause, Data, Effect, Layer, Option, Schema, Struct } from 'effect';
21
+ import open, { type AppName, apps } from 'open';
22
+ import * as Model from '../services/Model.js';
17
23
  import * as Typesync from '../services/Typesync.js';
18
24
 
19
- const hypergraphTypeSyncApi = HttpApi.make('HypergraphTypeSyncApi')
25
+ class HypergraphTypesyncStudioApiRouter extends HttpApiGroup.make('HypergraphTypesyncStudioApiRouter')
20
26
  .add(
21
- HttpApiGroup.make('SchemaStreamGroup')
22
- .add(
23
- // exposes an api endpoint at /api/vX/schema/events that is a stream of the current Schema parsed from the directory the hypergraph-cli tool is running in
24
- HttpApiEndpoint.get('HypergraphSchemaEventStream')`/schema/events`
25
- .addError(HttpApiError.InternalServerError)
26
- .addSuccess(
27
- Schema.String.pipe(
28
- HttpApiSchema.withEncoding({
29
- kind: 'Json',
30
- contentType: 'text/event-stream',
31
- }),
32
- ),
33
- ),
27
+ // exposes an api endpoint at /api/vX/schema/events that is a stream of the current Schema parsed from the directory the hypergraph-cli tool is running in
28
+ HttpApiEndpoint.get('HypergraphSchemaEventStream')`/schema/events`
29
+ .addError(HttpApiError.InternalServerError)
30
+ .addSuccess(
31
+ Schema.String.pipe(
32
+ HttpApiSchema.withEncoding({
33
+ kind: 'Json',
34
+ contentType: 'text/event-stream',
35
+ }),
36
+ ),
37
+ ),
38
+ )
39
+ .add(
40
+ HttpApiEndpoint.post('SyncHypergraphSchema')`/schema/sync`
41
+ .setPayload(Model.TypesyncHypergraphSchema)
42
+ .addSuccess(Model.TypesyncHypergraphSchema)
43
+ .addError(HttpApiError.InternalServerError)
44
+ .addError(HttpApiError.BadRequest),
45
+ )
46
+ .add(
47
+ HttpApiEndpoint.post('SyncHypergraphMapping')`/mapping/sync`
48
+ .setPayload(
49
+ Schema.Struct({
50
+ schema: Model.TypesyncHypergraphSchema,
51
+ mapping: Model.TypesyncHypergraphMapping,
52
+ }),
34
53
  )
35
- .prefix('/v1'),
54
+ .addSuccess(Model.TypesyncHypergraphSchema)
55
+ .addError(HttpApiError.InternalServerError)
56
+ .addError(HttpApiError.BadRequest),
36
57
  )
37
- .prefix('/api');
58
+ .prefix('/v1') {}
59
+ class HypergraphTypesyncStudioApi extends HttpApi.make('HypergraphTypesyncStudioApi')
60
+ .add(HypergraphTypesyncStudioApiRouter)
61
+ .prefix('/api') {}
38
62
 
39
- const hypergraphTypeSyncApiLive = HttpApiBuilder.group(hypergraphTypeSyncApi, 'SchemaStreamGroup', (handlers) =>
40
- handlers.handle('HypergraphSchemaEventStream', () =>
63
+ const hypergraphTypeSyncApiLive = HttpApiBuilder.group(
64
+ HypergraphTypesyncStudioApi,
65
+ 'HypergraphTypesyncStudioApiRouter',
66
+ (handlers) =>
41
67
  Effect.gen(function* () {
42
68
  const schemaStream = yield* Typesync.TypesyncSchemaStreamBuilder;
43
69
 
44
- const stream = yield* schemaStream
45
- .hypergraphSchemaStream()
46
- .pipe(Effect.catchAll(() => new HttpApiError.InternalServerError()));
70
+ return handlers
71
+ .handle('HypergraphSchemaEventStream', () =>
72
+ Effect.gen(function* () {
73
+ const stream = yield* schemaStream.hypergraphSchemaStream().pipe(
74
+ Effect.tapErrorCause((cause) =>
75
+ Effect.logError(
76
+ AnsiDoc.cat(
77
+ AnsiDoc.text('Failure building Hypergraph events stream:'),
78
+ AnsiDoc.text(Cause.pretty(cause)),
79
+ ),
80
+ ),
81
+ ),
82
+ Effect.catchAll(() => new HttpApiError.InternalServerError()),
83
+ );
47
84
 
48
- return yield* HttpServerResponse.stream(stream, { contentType: 'text/event-stream' }).pipe(
49
- HttpServerResponse.setHeaders({
50
- 'Content-Type': 'text/event-stream',
51
- 'Cache-Control': 'no-cache',
52
- Connection: 'keep-alive',
53
- }),
54
- );
85
+ return yield* HttpServerResponse.stream(stream, { contentType: 'text/event-stream' }).pipe(
86
+ HttpServerResponse.setHeaders({
87
+ 'Content-Type': 'text/event-stream',
88
+ 'Cache-Control': 'no-cache',
89
+ Connection: 'keep-alive',
90
+ }),
91
+ );
92
+ }),
93
+ )
94
+ .handle('SyncHypergraphSchema', ({ payload }) =>
95
+ schemaStream.syncSchema(payload).pipe(
96
+ Effect.tapErrorCause((cause) =>
97
+ Effect.logError(
98
+ AnsiDoc.cat(AnsiDoc.text('Failure syncing Hypergraph Schema:'), AnsiDoc.text(Cause.pretty(cause))),
99
+ ),
100
+ ),
101
+ Effect.catchAll(() => new HttpApiError.InternalServerError()),
102
+ ),
103
+ )
104
+ .handle('SyncHypergraphMapping', ({ payload }) =>
105
+ schemaStream.syncMapping(payload.schema, payload.mapping).pipe(
106
+ Effect.tapErrorCause((cause) =>
107
+ Effect.logError(
108
+ AnsiDoc.cat(AnsiDoc.text('Failure syncing Hypergraph mapping:'), AnsiDoc.text(Cause.pretty(cause))),
109
+ ),
110
+ ),
111
+ Effect.catchAll(() => new HttpApiError.InternalServerError()),
112
+ ),
113
+ );
55
114
  }),
56
- ),
57
115
  );
58
116
 
59
- const HypergraphTypeSyncApiLive = HttpApiBuilder.api(hypergraphTypeSyncApi).pipe(
60
- Layer.provide(hypergraphTypeSyncApiLive),
117
+ const HypergraphTypeSyncApiLayer = HttpApiBuilder.middlewareCors({
118
+ allowedMethods: ['GET', 'POST', 'OPTIONS'],
119
+ allowedOrigins: ['http://localhost:3000', 'http://localhost:5173'],
120
+ }).pipe(
121
+ Layer.provideMerge(HttpApiBuilder.api(HypergraphTypesyncStudioApi)),
61
122
  Layer.provide(Typesync.layer),
123
+ Layer.provide(hypergraphTypeSyncApiLive),
124
+ );
125
+
126
+ const HypergraphTypeSyncApiLive = HttpApiBuilder.httpApp.pipe(
127
+ Effect.provide(
128
+ Layer.mergeAll(HypergraphTypeSyncApiLayer, HttpApiBuilder.Router.Live, HttpApiBuilder.Middleware.layer),
129
+ ),
62
130
  );
63
131
 
64
- const HypergraphTypeSyncApiLayer = HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
65
- Layer.provide(HttpApiBuilder.middlewareCors()),
66
- Layer.provide(HypergraphTypeSyncApiLive),
132
+ const TypesyncStudioFileRouter = Effect.gen(function* () {
133
+ const fs = yield* FileSystem.FileSystem;
134
+ const path = yield* Path.Path;
135
+
136
+ const __filename = fileURLToPath(import.meta.url);
137
+ const __dirname = path.dirname(__filename);
138
+
139
+ // Try multiple possible locations for the dist directory
140
+ const possiblePaths = [
141
+ // npm published package (when this file is in node_modules/@graphprotocol/hypergraph/dist/cli/subcommands/)
142
+ path.resolve(__dirname, '..', '..', 'typesync-studio', 'dist'),
143
+ // Development mode (when this file is in packages/hypergraph/src/cli/subcommands/)
144
+ path.resolve(__dirname, '..', '..', '..', 'typesync-studio', 'dist'),
145
+ ];
146
+
147
+ const findTypesyncStudioDist = Effect.fnUntraced(function* () {
148
+ return yield* Effect.findFirst(possiblePaths, (_) => fs.exists(_).pipe(Effect.orElseSucceed(() => false)));
149
+ });
150
+
151
+ const typesyncStudioClientDist = yield* findTypesyncStudioDist().pipe(
152
+ // default to first path
153
+ Effect.map((maybe) => Option.getOrElse(maybe, () => possiblePaths[0])),
154
+ );
155
+
156
+ return HttpRouter.empty.pipe(
157
+ HttpRouter.get(
158
+ '/',
159
+ HttpServerResponse.file(path.join(typesyncStudioClientDist, 'index.html')).pipe(
160
+ Effect.orElse(() => HttpServerResponse.empty({ status: 404 })),
161
+ ),
162
+ ),
163
+ HttpRouter.get(
164
+ '/assets/:file',
165
+ Effect.gen(function* () {
166
+ const file = yield* HttpRouter.params.pipe(Effect.map(Struct.get('file')), Effect.map(Option.fromNullable));
167
+
168
+ if (Option.isNone(file)) {
169
+ return HttpServerResponse.empty({ status: 404 });
170
+ }
171
+
172
+ const assets = path.join(typesyncStudioClientDist, 'assets');
173
+ const normalized = path.normalize(path.join(assets, ...file.value.split('/')));
174
+ if (!normalized.startsWith(assets)) {
175
+ return HttpServerResponse.empty({ status: 404 });
176
+ }
177
+
178
+ return yield* HttpServerResponse.file(normalized);
179
+ }).pipe(Effect.orElse(() => HttpServerResponse.empty({ status: 404 }))),
180
+ ),
181
+ );
182
+ });
183
+
184
+ const Server = Effect.all({
185
+ api: HypergraphTypeSyncApiLive,
186
+ files: TypesyncStudioFileRouter,
187
+ }).pipe(
188
+ Effect.map(({ api, files }) =>
189
+ HttpRouter.empty.pipe(HttpRouter.mount('/', files), HttpRouter.mountApp('/api', api, { includePrefix: true })),
190
+ ),
191
+ Effect.map((router) => HttpServer.serve(HttpMiddleware.logger)(router)),
192
+ Layer.unwrapEffect,
67
193
  );
68
194
 
195
+ const openBrowser = (port: number, browser: AppName | 'arc' | 'safari' | 'browser' | 'browserPrivate') =>
196
+ Effect.async<void, OpenBrowserError>((resume) => {
197
+ const url = `http://localhost:${port}`;
198
+
199
+ const launch = (appOpts?: { name: string | ReadonlyArray<string> }) =>
200
+ open(url, appOpts ? { app: appOpts } : undefined).then((subprocess) => {
201
+ subprocess.on('spawn', () => resume(Effect.void));
202
+ subprocess.on('error', (err) => resume(Effect.fail(new OpenBrowserError({ cause: err }))));
203
+ });
204
+
205
+ const mapBrowserName = (b: typeof browser): string | ReadonlyArray<string> | undefined => {
206
+ switch (b) {
207
+ case 'chrome':
208
+ return apps.chrome; // cross-platform alias from open
209
+ case 'firefox':
210
+ return apps.firefox;
211
+ case 'edge':
212
+ return apps.edge;
213
+ case 'safari':
214
+ return 'Safari';
215
+ case 'arc':
216
+ return 'Arc';
217
+ default:
218
+ return undefined;
219
+ }
220
+ };
221
+
222
+ switch (browser) {
223
+ case 'browser':
224
+ launch();
225
+ break;
226
+ case 'browserPrivate':
227
+ launch({ name: apps.browserPrivate });
228
+ break;
229
+ default: {
230
+ const mapped = mapBrowserName(browser);
231
+ if (mapped) {
232
+ launch({ name: mapped }).catch(() => launch());
233
+ break;
234
+ }
235
+ launch();
236
+ break;
237
+ }
238
+ }
239
+ });
240
+
241
+ export class OpenBrowserError extends Data.TaggedError('Nozzle/cli/studio/errors/OpenBrowserError')<{
242
+ readonly cause: unknown;
243
+ }> {}
244
+
69
245
  export const typesync = Command.make('typesync', {
70
246
  args: {
71
- port: Options.integer('port').pipe(
72
- Options.withAlias('p'),
73
- Options.withDefault(3000),
74
- Options.withDescription('The port to run the Hypergraph TypeSync studio server on. Default 3000'),
247
+ open: Options.boolean('open').pipe(
248
+ Options.withDescription('If true, opens the nozzle dataset studio in your browser'),
249
+ Options.withDefault(true),
250
+ ),
251
+ browser: Options.choice('browser', [
252
+ 'chrome',
253
+ 'firefox',
254
+ 'edge',
255
+ 'safari',
256
+ 'arc',
257
+ 'browser',
258
+ 'browserPrivate',
259
+ ]).pipe(
260
+ Options.withAlias('b'),
261
+ Options.withDescription(
262
+ 'Broweser to open the nozzle dataset studio app in. Default is your default selected browser',
263
+ ),
264
+ Options.withDefault('browser'),
75
265
  ),
76
266
  },
77
267
  }).pipe(
@@ -80,14 +270,33 @@ export const typesync = Command.make('typesync', {
80
270
  ),
81
271
  Command.withHandler(({ args }) =>
82
272
  Effect.gen(function* () {
83
- yield* HypergraphTypeSyncApiLayer.pipe(
273
+ yield* Server.pipe(
84
274
  HttpServer.withLogAddress,
85
- Layer.provide(NodeHttpServer.layer(createServer, { port: args.port })),
275
+ Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
276
+ Layer.tap(() =>
277
+ Effect.gen(function* () {
278
+ if (args.open) {
279
+ return yield* openBrowser(3000, args.browser).pipe(
280
+ Effect.tapErrorCause((cause) =>
281
+ Effect.logWarning(
282
+ AnsiDoc.text(
283
+ 'Failure opening nozzle dataset studio in your browser. Open at http://localhost:3000',
284
+ ),
285
+ AnsiDoc.text(Cause.pretty(cause)),
286
+ ),
287
+ ),
288
+ Effect.orElseSucceed(() => Effect.void),
289
+ );
290
+ }
291
+ return Effect.void;
292
+ }),
293
+ ),
86
294
  Layer.tap(() =>
87
- Effect.logInfo(AnsiDoc.text(`🎉 TypeSync studio started and running at http://localhost:${args.port}`)),
295
+ Effect.logInfo(AnsiDoc.text('🎉 TypeSync studio started and running at http://localhost:3000')),
88
296
  ),
89
297
  Layer.launch,
90
298
  );
91
299
  }),
92
300
  ),
301
+ Command.provide(Typesync.layer),
93
302
  );