@graphprotocol/hypergraph 0.3.0 → 0.4.1

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 (46) hide show
  1. package/dist/cli/Cli.d.ts +2 -0
  2. package/dist/cli/Cli.d.ts.map +1 -0
  3. package/dist/cli/Cli.js +8 -0
  4. package/dist/cli/Cli.js.map +1 -0
  5. package/dist/cli/Logger.d.ts +3 -0
  6. package/dist/cli/Logger.d.ts.map +1 -0
  7. package/dist/cli/Logger.js +20 -0
  8. package/dist/cli/Logger.js.map +1 -0
  9. package/dist/cli/bin.d.ts +3 -0
  10. package/dist/cli/bin.d.ts.map +1 -0
  11. package/dist/cli/bin.js +20 -0
  12. package/dist/cli/bin.js.map +1 -0
  13. package/dist/cli/bun.d.ts +3 -0
  14. package/dist/cli/bun.d.ts.map +1 -0
  15. package/dist/cli/bun.js +3 -0
  16. package/dist/cli/bun.js.map +1 -0
  17. package/dist/cli/services/Typesync.d.ts +21 -0
  18. package/dist/cli/services/Typesync.d.ts.map +1 -0
  19. package/dist/cli/services/Typesync.js +137 -0
  20. package/dist/cli/services/Typesync.js.map +1 -0
  21. package/dist/cli/services/Utils.d.ts +10 -0
  22. package/dist/cli/services/Utils.d.ts.map +1 -0
  23. package/dist/cli/services/Utils.js +154 -0
  24. package/dist/cli/services/Utils.js.map +1 -0
  25. package/dist/cli/subcommands/typesync.d.ts +7 -0
  26. package/dist/cli/subcommands/typesync.d.ts.map +1 -0
  27. package/dist/cli/subcommands/typesync.js +38 -0
  28. package/dist/cli/subcommands/typesync.js.map +1 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/mapping/Mapping.d.ts +44 -32
  34. package/dist/mapping/Mapping.d.ts.map +1 -1
  35. package/dist/mapping/Mapping.js +45 -23
  36. package/dist/mapping/Mapping.js.map +1 -1
  37. package/package.json +7 -3
  38. package/src/cli/Cli.ts +15 -0
  39. package/src/cli/Logger.ts +20 -0
  40. package/src/cli/bin.ts +33 -0
  41. package/src/cli/bun.ts +3 -0
  42. package/src/cli/services/Typesync.ts +189 -0
  43. package/src/cli/services/Utils.ts +187 -0
  44. package/src/cli/subcommands/typesync.ts +93 -0
  45. package/src/index.ts +1 -0
  46. package/src/mapping/Mapping.ts +62 -40
@@ -0,0 +1,189 @@
1
+ import { FileSystem, Path } from '@effect/platform';
2
+ import { NodeFileSystem } from '@effect/platform-node';
3
+ import { AnsiDoc } from '@effect/printer-ansi';
4
+ import { Cause, Data, Effect, Array as EffectArray, Option, Stream } from 'effect';
5
+ import type { NonEmptyReadonlyArray } from 'effect/Array';
6
+ import type { Schema as HypergraphSchema, Mapping } from '../../mapping/Mapping.js';
7
+ import { parseHypergraphMapping, parseSchema } from './Utils.js';
8
+
9
+ export class TypesyncSchemaStreamBuilder extends Effect.Service<TypesyncSchemaStreamBuilder>()(
10
+ '/Hypergraph/cli/services/TypesyncSchemaStreamBuilder',
11
+ {
12
+ dependencies: [NodeFileSystem.layer],
13
+ effect: Effect.gen(function* () {
14
+ const fs = yield* FileSystem.FileSystem;
15
+ const path = yield* Path.Path;
16
+
17
+ const encoder = new TextEncoder();
18
+
19
+ const schemaCandidates = (cwd = '.') =>
20
+ EffectArray.make(
21
+ path.resolve(cwd, 'schema.ts'),
22
+ path.resolve(cwd, 'src/schema.ts'),
23
+ path.resolve(cwd, 'app/schema.ts'),
24
+ path.resolve(cwd, 'src/app/schema.ts'),
25
+ // @todo other possible locations?
26
+ );
27
+ const mappingCandidates = (cwd = '.') =>
28
+ EffectArray.make(
29
+ path.resolve(cwd, 'mapping.ts'),
30
+ path.resolve(cwd, 'src/mapping.ts'),
31
+ path.resolve(cwd, 'app/mapping.ts'),
32
+ path.resolve(cwd, 'src/app/mapping.ts'),
33
+ // @todo other possible locations?
34
+ );
35
+
36
+ const jiti = yield* Effect.tryPromise({
37
+ async try() {
38
+ const { createJiti } = await import('jiti');
39
+ return createJiti(import.meta.url, { moduleCache: false, tryNative: false });
40
+ },
41
+ catch(cause) {
42
+ return new MappingLoaderError({ cause });
43
+ },
44
+ }).pipe(Effect.cached);
45
+
46
+ const loadMapping = Effect.fnUntraced(function* (mappingFilePath: string) {
47
+ return yield* Effect.tryMapPromise(jiti, {
48
+ try(instance) {
49
+ return instance.import(mappingFilePath);
50
+ },
51
+ catch(cause) {
52
+ return cause;
53
+ },
54
+ }).pipe(
55
+ Effect.map(parseHypergraphMapping),
56
+ Effect.mapError(
57
+ (cause) => new MappingLoaderError({ cause, message: `Failed to load mapping file ${mappingFilePath}` }),
58
+ ),
59
+ Effect.tapErrorCause((cause) =>
60
+ Effect.logWarning(
61
+ AnsiDoc.cats([AnsiDoc.text('Failure loading mapping'), AnsiDoc.text(Cause.pretty(cause))]),
62
+ ),
63
+ ),
64
+ Effect.orElseSucceed(() => ({}) as Mapping),
65
+ );
66
+ });
67
+
68
+ const findHypergraphSchema = Effect.fnUntraced(function* (candidates: NonEmptyReadonlyArray<string>) {
69
+ return yield* Effect.findFirst(candidates, (_) => fs.exists(_).pipe(Effect.orElseSucceed(() => false)));
70
+ });
71
+
72
+ /**
73
+ * Reads the schema.ts file, and maybe reads the mapping.ts file (if exists).
74
+ * Parses the schema and from it, plus the loaded mapping, creates a Stream of the Hypergraph [Schema](../../mapping/Mapping.ts).
75
+ * This represents the state of the schema when the user hits the schema stream endpoint
76
+ *
77
+ * @param schemaFilePath path of the schema.ts file
78
+ * @param mappingFilePath [Optional] path of the mapping.ts file
79
+ * @returns A stream of [Schema](../../mapping/Mapping.ts) pared from the schema.ts file
80
+ */
81
+ const currentSchemaStream = (
82
+ schemaFilePath: Option.Option<string>,
83
+ mappingFilePath: Option.Option<string>,
84
+ ): Stream.Stream<HypergraphSchema, never, never> =>
85
+ Stream.fromEffect(
86
+ Effect.gen(function* () {
87
+ const schema = yield* Option.match(schemaFilePath, {
88
+ onNone: () => Effect.succeed(''),
89
+ onSome: fs.readFileString,
90
+ });
91
+ const mapping = yield* Option.match(mappingFilePath, {
92
+ onNone: () => Effect.succeed({} as Mapping),
93
+ onSome: loadMapping,
94
+ });
95
+ return yield* parseSchema(schema, mapping);
96
+ }),
97
+ ).pipe(
98
+ Stream.tapErrorCause((cause) =>
99
+ Effect.logError(
100
+ AnsiDoc.text('Failure parsing current schema into types'),
101
+ AnsiDoc.text(Cause.pretty(cause)),
102
+ ),
103
+ ),
104
+ // if failure, don't bubble to return and just return empty schema
105
+ Stream.orElseSucceed(() => ({ types: [] }) satisfies HypergraphSchema),
106
+ );
107
+ /**
108
+ * Reads the schema.ts file, and maybe reads the mapping.ts file (if exists).
109
+ * Parses the schema and from it, plus the loaded mapping, creates a Stream of the Hypergraph [Schema](../../mapping/Mapping.ts).
110
+ * This stream watches for changes in both the schema.ts file and (if provided) the mapping.ts file.
111
+ * This way, if the user updates either, this will emit an event on the stream of the updated schema.
112
+ *
113
+ * @param schemaFilePath path of the schema.ts file
114
+ * @param mappingFilePath [Optional] path of the mapping.ts file
115
+ * @returns A stream of [Schema](../../mapping/Mapping.ts) pared from the schema.ts file
116
+ */
117
+ const watchSchemaStream = (
118
+ schemaFilePath: Option.Option<string>,
119
+ mappingFilePath: Option.Option<string>,
120
+ ): Stream.Stream<HypergraphSchema, never, never> => {
121
+ const schemaWatch = Option.match(schemaFilePath, {
122
+ // @todo watch the root here so if a schema is created, it will get picked up
123
+ onNone: () => Stream.empty,
124
+ onSome: fs.watch,
125
+ });
126
+ const mappingWatch = Option.match(mappingFilePath, {
127
+ onNone: () => Stream.empty,
128
+ onSome: fs.watch,
129
+ });
130
+
131
+ return Stream.mergeAll([schemaWatch, mappingWatch], { concurrency: 2 }).pipe(
132
+ Stream.buffer({ capacity: 1, strategy: 'sliding' }),
133
+ Stream.mapEffect(() =>
134
+ Effect.gen(function* () {
135
+ const schema = yield* Option.match(schemaFilePath, {
136
+ onNone: () => Effect.succeed(''),
137
+ onSome: fs.readFileString,
138
+ });
139
+ const mapping = yield* Option.match(mappingFilePath, {
140
+ onNone: () => Effect.succeed({} as Mapping),
141
+ onSome: loadMapping,
142
+ });
143
+ return yield* parseSchema(schema, mapping);
144
+ }),
145
+ ),
146
+ Stream.tapErrorCause((cause) =>
147
+ Effect.logError(AnsiDoc.text('Failure parsing schema changes into types'), { cause: Cause.pretty(cause) }),
148
+ ),
149
+ // if failure, don't bubble to return and just return empty schema
150
+ Stream.orElseSucceed(() => ({ types: [] }) satisfies HypergraphSchema),
151
+ );
152
+ };
153
+
154
+ const hypergraphSchemaStream = (cwd = '.') =>
155
+ Effect.gen(function* () {
156
+ const schemaFileCandidates = schemaCandidates(cwd);
157
+ // Fetch the Schema definition from any schema.ts in the directory.
158
+ // If exists, use it to parse the Hypergraph schema
159
+ const schemaFilePath = yield* findHypergraphSchema(schemaFileCandidates);
160
+ if (Option.isNone(schemaFilePath)) {
161
+ yield* Effect.logDebug(
162
+ AnsiDoc.text('No Hypergraph schema file found. Searched:'),
163
+ AnsiDoc.cats(schemaFileCandidates.map((candidate) => AnsiDoc.text(candidate))),
164
+ );
165
+ }
166
+ // Fetch the Mapping definition from any mapping.ts in the directory.
167
+ // If exists, use it to get the knowledgeGraphId for each type/property in the parsed schema
168
+ const mappingFilePath = yield* findHypergraphSchema(mappingCandidates(cwd));
169
+
170
+ return currentSchemaStream(schemaFilePath, mappingFilePath).pipe(
171
+ Stream.concat(watchSchemaStream(schemaFilePath, mappingFilePath)),
172
+ Stream.map((stream) => {
173
+ const jsonData = JSON.stringify(stream);
174
+ const sseData = `data: ${jsonData}\n\n`;
175
+ return encoder.encode(sseData);
176
+ }),
177
+ );
178
+ });
179
+
180
+ return { hypergraphSchemaStream } as const;
181
+ }),
182
+ },
183
+ ) {}
184
+ export const layer = TypesyncSchemaStreamBuilder.Default;
185
+
186
+ export class MappingLoaderError extends Data.TaggedError('/Hypergraph/cli/errors/MappingLoaderError')<{
187
+ readonly cause: unknown;
188
+ readonly message?: string;
189
+ }> {}
@@ -0,0 +1,187 @@
1
+ import { Data, Effect, Array as EffectArray } from 'effect';
2
+ import ts from 'typescript';
3
+
4
+ import * as Mapping from '../../mapping/Mapping.js';
5
+ import * as Utils from '../../mapping/Utils.js';
6
+
7
+ /**
8
+ * Takes a parsed schema.ts file and maps it to a the Mapping.Schema type.
9
+ *
10
+ * @internal
11
+ *
12
+ * @param sourceCode the read schema.ts file
13
+ * @param mapping the parsed mappint.ts file
14
+ * @returns the parsed Schema instance
15
+ */
16
+ export const parseSchema = (
17
+ sourceCode: string,
18
+ mapping: Mapping.Mapping,
19
+ ): Effect.Effect<Mapping.Schema, SchemaParserFailure> =>
20
+ Effect.try({
21
+ try() {
22
+ const sourceFile = ts.createSourceFile('schema.ts', sourceCode, ts.ScriptTarget.Latest, true);
23
+
24
+ const entities: Array<Mapping.SchemaType> = [];
25
+
26
+ const visit = (node: ts.Node) => {
27
+ if (ts.isClassDeclaration(node) && node.name) {
28
+ const className = node.name.text;
29
+ const properties: Array<Mapping.SchemaTypePropertyPrimitive | Mapping.SchemaTypePropertyRelation> = [];
30
+
31
+ // Find the Entity.Class call
32
+ if (node.heritageClauses) {
33
+ for (const clause of node.heritageClauses) {
34
+ for (const type of clause.types) {
35
+ if (ts.isCallExpression(type.expression)) {
36
+ const callExpr = type.expression;
37
+
38
+ // Look for the object literal with properties
39
+ if (callExpr.arguments.length > 0) {
40
+ const arg = callExpr.arguments[0];
41
+ if (ts.isObjectLiteralExpression(arg)) {
42
+ for (const prop of arg.properties) {
43
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
44
+ const propName = prop.name.text;
45
+ let dataType: Mapping.SchemaDataType = 'String';
46
+ let relationType: string | undefined;
47
+
48
+ const mappingEntry = mapping[className];
49
+ const camelCasePropName = Utils.toCamelCase(propName);
50
+
51
+ // Extract the type
52
+ if (ts.isPropertyAccessExpression(prop.initializer)) {
53
+ // Simple types like Type.Text
54
+ dataType = Mapping.getDataType(prop.initializer.name.text);
55
+
56
+ // Look up the property's knowledgeGraphId from the mapping
57
+ const propKnowledgeGraphId = mappingEntry?.properties?.[camelCasePropName] || null;
58
+
59
+ // push to type properties as primitive property
60
+ properties.push({
61
+ name: propName,
62
+ dataType: dataType as Mapping.SchemaDataTypePrimitive,
63
+ knowledgeGraphId: propKnowledgeGraphId,
64
+ } satisfies Mapping.SchemaTypePropertyPrimitive);
65
+ } else if (ts.isCallExpression(prop.initializer)) {
66
+ // Relation types like Type.Relation(User)
67
+ const callNode = prop.initializer;
68
+ if (ts.isPropertyAccessExpression(callNode.expression)) {
69
+ const typeName = callNode.expression.name.text;
70
+
71
+ if (typeName === 'Relation' && callNode.arguments.length > 0) {
72
+ const relationArg = callNode.arguments[0];
73
+ if (ts.isIdentifier(relationArg)) {
74
+ relationType = relationArg.text;
75
+ dataType = `Relation(${relationType})`;
76
+
77
+ // Look up the relation property's knowledgeGraphId from the mapping
78
+ const relKnowledgeGraphId = mappingEntry?.relations?.[camelCasePropName] || null;
79
+
80
+ // push to type properties as relation property
81
+ properties.push({
82
+ name: propName,
83
+ dataType,
84
+ relationType,
85
+ knowledgeGraphId: relKnowledgeGraphId,
86
+ } satisfies Mapping.SchemaTypePropertyRelation);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ // Look up the type's knowledgeGraphId from the mapping
101
+ const mappingEntry = mapping[Utils.toPascalCase(className)];
102
+ const typeKnowledgeGraphId = mappingEntry?.typeIds?.[0] ? mappingEntry.typeIds[0] : null;
103
+
104
+ entities.push({ name: className, knowledgeGraphId: typeKnowledgeGraphId, properties });
105
+ }
106
+
107
+ ts.forEachChild(node, visit);
108
+ };
109
+
110
+ visit(sourceFile);
111
+
112
+ return {
113
+ types: entities,
114
+ } as const;
115
+ },
116
+ catch(error) {
117
+ return new SchemaParserFailure({
118
+ message: `Failed to parse schema: ${error}`,
119
+ cause: error,
120
+ });
121
+ },
122
+ });
123
+
124
+ export class SchemaParserFailure extends Data.TaggedError('/Hypergraph/cli/errors/SchemaParserFailure')<{
125
+ readonly message: string;
126
+ readonly cause: unknown;
127
+ }> {}
128
+
129
+ /**
130
+ * @internal
131
+ *
132
+ * Runtime check to see if a value looks like a Mapping
133
+ */
134
+ function isMappingLike(value: unknown): value is Mapping.Mapping {
135
+ if (!value || typeof value !== 'object') return false;
136
+ return Object.values(value).every(
137
+ (entry) =>
138
+ entry &&
139
+ typeof entry === 'object' &&
140
+ 'typeIds' in entry &&
141
+ // biome-ignore lint/suspicious/noExplicitAny: parsing so type unknown
142
+ EffectArray.isArray((entry as any).typeIds),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * @internal
148
+ *
149
+ * Look at the exported object from the mapping.ts file loaded via jiti and try to pull out the hypergraph mapping.
150
+ * Default should be:
151
+ * ```typescript
152
+ * export const mapping: Mapping
153
+ * ```
154
+ * But this is not guaranteed. This function looks through the file to try to find it using some logical defaults/checks.
155
+ *
156
+ * @param moduleExport the object imported from jiti from the mapping.ts file
157
+ */
158
+ // biome-ignore lint/suspicious/noExplicitAny: type should be import object from jiti
159
+ export function parseHypergraphMapping(moduleExport: any): Mapping.Mapping {
160
+ // Handle null/undefined inputs
161
+ if (!moduleExport || typeof moduleExport !== 'object') {
162
+ return {} as Mapping.Mapping;
163
+ }
164
+
165
+ // Find all exports that look like Mapping objects
166
+ const mappingCandidates = Object.entries(moduleExport).filter(([, value]) => isMappingLike(value));
167
+
168
+ if (mappingCandidates.length === 0) {
169
+ return {} as Mapping.Mapping;
170
+ }
171
+
172
+ if (mappingCandidates.length === 1) {
173
+ return mappingCandidates[0][1] as Mapping.Mapping;
174
+ }
175
+
176
+ // Multiple candidates - prefer common names
177
+ const preferredNames = ['mapping', 'default', 'config'];
178
+ for (const preferredName of preferredNames) {
179
+ const found = mappingCandidates.find(([name]) => name === preferredName);
180
+ if (found) {
181
+ return found[1] as Mapping.Mapping;
182
+ }
183
+ }
184
+
185
+ // If no preferred names found, use the first one
186
+ return mappingCandidates[0][1] as Mapping.Mapping;
187
+ }
@@ -0,0 +1,93 @@
1
+ import { createServer } from 'node:http';
2
+ import { Command, Options } from '@effect/cli';
3
+ import {
4
+ HttpApi,
5
+ HttpApiBuilder,
6
+ HttpApiEndpoint,
7
+ HttpApiError,
8
+ HttpApiGroup,
9
+ HttpApiSchema,
10
+ HttpMiddleware,
11
+ HttpServer,
12
+ HttpServerResponse,
13
+ } from '@effect/platform';
14
+ import { NodeHttpServer } from '@effect/platform-node';
15
+ import { AnsiDoc } from '@effect/printer-ansi';
16
+ import { Effect, Layer, Schema } from 'effect';
17
+ import * as Typesync from '../services/Typesync.js';
18
+
19
+ const hypergraphTypeSyncApi = HttpApi.make('HypergraphTypeSyncApi')
20
+ .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
+ ),
34
+ )
35
+ .prefix('/v1'),
36
+ )
37
+ .prefix('/api');
38
+
39
+ const hypergraphTypeSyncApiLive = HttpApiBuilder.group(hypergraphTypeSyncApi, 'SchemaStreamGroup', (handlers) =>
40
+ handlers.handle('HypergraphSchemaEventStream', () =>
41
+ Effect.gen(function* () {
42
+ const schemaStream = yield* Typesync.TypesyncSchemaStreamBuilder;
43
+
44
+ const stream = yield* schemaStream
45
+ .hypergraphSchemaStream()
46
+ .pipe(Effect.catchAll(() => new HttpApiError.InternalServerError()));
47
+
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
+ );
55
+ }),
56
+ ),
57
+ );
58
+
59
+ const HypergraphTypeSyncApiLive = HttpApiBuilder.api(hypergraphTypeSyncApi).pipe(
60
+ Layer.provide(hypergraphTypeSyncApiLive),
61
+ Layer.provide(Typesync.layer),
62
+ );
63
+
64
+ const HypergraphTypeSyncApiLayer = HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
65
+ Layer.provide(HttpApiBuilder.middlewareCors()),
66
+ Layer.provide(HypergraphTypeSyncApiLive),
67
+ );
68
+
69
+ export const typesync = Command.make('typesync', {
70
+ 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'),
75
+ ),
76
+ },
77
+ }).pipe(
78
+ Command.withDescription(
79
+ 'Opens the TypeSync studio to help users build and publish their Hypergraph application schema',
80
+ ),
81
+ Command.withHandler(({ args }) =>
82
+ Effect.gen(function* () {
83
+ yield* HypergraphTypeSyncApiLayer.pipe(
84
+ HttpServer.withLogAddress,
85
+ Layer.provide(NodeHttpServer.layer(createServer, { port: args.port })),
86
+ Layer.tap(() =>
87
+ Effect.logInfo(AnsiDoc.text(`🎉 TypeSync studio started and running at http://localhost:${args.port}`)),
88
+ ),
89
+ Layer.launch,
90
+ );
91
+ }),
92
+ ),
93
+ );
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { Id } from '@graphprotocol/grc-20';
1
2
  export * as Connect from './connect/index.js';
2
3
  export * as Entity from './entity/index.js';
3
4
  export * as Identity from './identity/index.js';