diff --git a/.changeset/tame-horses-try.md b/.changeset/tame-horses-try.md new file mode 100644 index 00000000..c75fcd55 --- /dev/null +++ b/.changeset/tame-horses-try.md @@ -0,0 +1,13 @@ +--- +"@graphprotocol/hypergraph": minor +--- + +Introduced a cli tool and typesync studio UI to let users graphically visualize, update, and publish their hypergraph schemas. + +1. install the latest `@graphprotocol/hypergraph` version: `pnpm add @graphprotocol/hypergraph@latest` +2. add a script to your `package.json` to open the typesync studio: `"typesync": "hg typesync --open"` +3. open your browser to http://localhost:3000 +4. view your current Hypergraph app schema, parsed from your `schema.ts` file +5. update your schema, view existing types and properties on the Knowledge Graph. add types and properties. +6. sync your schema updates to your schema.ts file +7. publish your schema to the Knowledge Graph. \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b7ee88a..b670e734 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,13 @@ { + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + }, "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit" diff --git a/apps/connect/package.json b/apps/connect/package.json index 2d9a9a4b..bed6f0e6 100644 --- a/apps/connect/package.json +++ b/apps/connect/package.json @@ -25,10 +25,10 @@ "@tanstack/react-router-devtools": "^1.122.0", "@xstate/store": "^3.5.1", "clsx": "^2.1.1", - "effect": "^3.17.3", + "effect": "^3.17.6", "graphql-request": "^7.2.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", "tailwind-merge": "^3.3.1", "viem": "^2.30.6", "vite": "^6.3.5" @@ -37,8 +37,8 @@ "@tailwindcss/vite": "^4.1.11", "@tanstack/router-plugin": "^1.120.2", "@types/node": "^24.1.0", - "@types/react": "^19.1.3", - "@types/react-dom": "^19.1.3", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.4.1", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/apps/create-hypergraph/package.json b/apps/create-hypergraph/package.json index c43449d9..7f5cfd39 100644 --- a/apps/create-hypergraph/package.json +++ b/apps/create-hypergraph/package.json @@ -55,15 +55,15 @@ "homepage": "https://github.com/graphprotocol/hypergraph/tree/main/apps/create-hypergraph-app#readme", "devDependencies": { "@effect/cli": "^0.69.0", - "@effect/language-service": "^0.31.2", + "@effect/language-service": "^0.34.0", "@effect/platform": "^0.90.0", - "@effect/platform-node": "^0.94.0", + "@effect/platform-node": "^0.94.1", "@effect/printer-ansi": "^0.45.0", "@effect/vitest": "^0.25.0", - "@types/node": "^24.1.0", - "effect": "^3.17.3", + "@types/node": "^24.2.0", + "effect": "^3.17.6", "execa": "^9.6.0", - "tsdown": "^0.13.0", + "tsdown": "^0.13.3", "tsx": "^4.20.3" } } diff --git a/apps/create-hypergraph/template-nextjs/package.json b/apps/create-hypergraph/template-nextjs/package.json index bd9044be..ceccfb30 100644 --- a/apps/create-hypergraph/template-nextjs/package.json +++ b/apps/create-hypergraph/template-nextjs/package.json @@ -26,8 +26,8 @@ "lucide-react": "^0.525.0", "next": "15.4.3", "postcss": "^8.5.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, diff --git a/apps/create-hypergraph/template-vite-react/package.json b/apps/create-hypergraph/template-vite-react/package.json index 86799577..4dcaa0ed 100644 --- a/apps/create-hypergraph/template-vite-react/package.json +++ b/apps/create-hypergraph/template-vite-react/package.json @@ -21,10 +21,10 @@ "@tanstack/react-router": "^1.129.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "effect": "^3.17.3", + "effect": "^3.17.6", "lucide-react": "^0.525.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "vite": "^7.0.5" @@ -32,9 +32,9 @@ "devDependencies": { "@eslint/js": "^9.31.0", "@tanstack/router-plugin": "^1.129.2", - "@types/node": "^24.1.0", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@types/node": "^24.2.0", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.31.0", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/apps/events/package.json b/apps/events/package.json index 706cf556..ed4a79a5 100644 --- a/apps/events/package.json +++ b/apps/events/package.json @@ -23,13 +23,13 @@ "@xstate/store": "^3.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "effect": "^3.17.3", + "effect": "^3.17.6", "framer-motion": "^12.10.1", "graphql-request": "^7.1.2", "isomorphic-ws": "^5.0.0", "lucide-react": "^0.508.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", "react-select": "^5.10.1", "siwe": "^3.0.0", "tailwind-merge": "^3.2.0", @@ -42,9 +42,9 @@ "@biomejs/biome": "1.9.4", "@tailwindcss/vite": "^4.1.5", "@tanstack/router-plugin": "^1.120.2", - "@types/node": "^24.1.0", - "@types/react": "^19.1.3", - "@types/react-dom": "^19.1.3", + "@types/node": "^24.2.0", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.4.1", "globals": "^16.1.0", diff --git a/apps/events/src/mapping.ts b/apps/events/src/mapping.ts index 7db1532c..ecc292a3 100644 --- a/apps/events/src/mapping.ts +++ b/apps/events/src/mapping.ts @@ -1,15 +1,43 @@ -import type { Mapping } from '@graphprotocol/hypergraph'; import { Id } from '@graphprotocol/hypergraph'; +import type { Mapping } from '@graphprotocol/hypergraph/mapping'; -export const mapping: Mapping.Mapping = { - Event: { - typeIds: [Id('7f9562d4-034d-4385-bf5c-f02cdebba47a')], +export const mapping: Mapping = { + User: { + typeIds: [Id('bffa181e-a333-495b-949c-57f2831d7eca')], properties: { - name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), - description: Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'), + name: Id('c9c79675-850a-42c5-bbbd-9e5c55d3f4e7'), + created: Id('f8df1caf-14b4-4c1e-85fb-4e97f7d7070a'), + }, + }, + Todo: { + typeIds: [Id('44fe82a9-e4c2-4330-a395-ce85ed78e421')], + properties: { + name: Id('c668aa67-bbca-4b2c-908c-9c5599035eab'), + completed: Id('71e7654f-2623-4794-88fb-841c8f3dd9b4'), }, relations: { - sponsors: Id('6860bfac-f703-4289-b789-972d0aaf3abe'), + assignees: Id('5b80d3ee-2463-4246-b628-44ba808ab3e1'), + }, + }, + Todo2: { + typeIds: [Id('210f4e94-234c-49d7-af0f-f3b74fb07650')], + properties: { + name: Id('e291f4da-632d-4b70-aca8-5c6c01dbf1ca'), + checked: Id('d1cc82ef-8bde-45f4-b31c-56b6d59279ec'), + due: Id('6a28f275-b31c-47bc-83cd-ad416aaa7073'), + amount: Id('0c8219be-e284-4738-bd95-91a1c113c78e'), + point: Id('7f032477-c60e-4c85-a161-019b70db05ca'), + website: Id('75b6a647-5c2b-41e7-92c0-b0a0c9b28b02'), + }, + relations: { + assignees: Id('1115e9f8-db2e-41df-8969-c5d34c367c10'), + }, + }, + JobOffer: { + typeIds: [Id('f60585af-71b6-4674-9a26-b74ca6c1cceb')], + properties: { + name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + salary: Id('baa36ac9-78ac-4cf7-8394-6b2d3006bebe'), }, }, Company: { @@ -21,32 +49,24 @@ export const mapping: Mapping.Mapping = { jobOffers: Id('1203064e-9741-4235-89d4-97f4b22eddfb'), }, }, - JobOffer: { - typeIds: [Id('f60585af-71b6-4674-9a26-b74ca6c1cceb')], + Event: { + typeIds: [Id('7f9562d4-034d-4385-bf5c-f02cdebba47a')], properties: { name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), - salary: Id('baa36ac9-78ac-4cf7-8394-6b2d3006bebe'), + description: Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'), + createdAt: Id('e2e6906b-d2b6-48d2-8aa2-54e8b29f6933'), + updatedAt: Id('2e877fe0-a504-4ea0-b43c-210d011db434'), + }, + relations: { + sponsors: Id('6860bfac-f703-4289-b789-972d0aaf3abe'), + }, + }, + Todo3: { + typeIds: [Id('4f7bba76-7855-4d63-b59d-1d9f2be866df')], + properties: { + name: Id('47006386-d351-411c-8287-1dae1c1aa8c1'), + completed: Id('9f9f00eb-4f32-4f71-92ba-b266566d0013'), + description: Id('89cac80a-1dbd-4bca-97b2-45e1556d9122'), }, }, - - // Todo2: { - // typeIds: [Id('LJuM8ju67mCv78FhAiK9k9')], - // properties: { - // name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), - // checked: Id('Ud9kn9gAUsCr1pxvxcgDj8'), - // due: Id('CFisPgjjWVdnaMtSWJDBqA'), - // point: Id('BkcVo7JZHF5LsWw7XZJwwe'), - // website: Id('XZmLQ8XyaUHnNWgSSbzaHU'), - // amount: Id('LfzKTfgy5Qg3PxAfKB2BL7'), - // }, - // relations: { - // assignees: Id('HCdFcTRyMyZMXScKox738i'), - // }, - // }, - // User: { - // typeIds: [Id('Fk5qzwdpKsD35gm5ts4SZA')], - // properties: { - // name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), - // }, - // }, }; diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index c60a3909..60ae225b 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -2,6 +2,7 @@ import { Entity, Type } from '@graphprotocol/hypergraph'; export class User extends Entity.Class('User')({ name: Type.String, + created: Type.Date, }) {} export class Todo extends Entity.Class('Todo')({ @@ -27,12 +28,19 @@ export class JobOffer extends Entity.Class('JobOffer')({ export class Company extends Entity.Class('Company')({ name: Type.String, - // address: Type.String, jobOffers: Type.Relation(JobOffer), }) {} export class Event extends Entity.Class('Event')({ name: Type.String, - description: Type.optional(Type.String), + description: Type.String, sponsors: Type.Relation(Company), + createdAt: Type.Date, + updatedAt: Type.Date, +}) {} + +export class Todo3 extends Entity.Class('Todo3')({ + name: Type.String, + completed: Type.Boolean, + description: Type.String, }) {} diff --git a/apps/next-example/package.json b/apps/next-example/package.json index bd796655..3f8e344e 100644 --- a/apps/next-example/package.json +++ b/apps/next-example/package.json @@ -15,8 +15,8 @@ "@graphprotocol/hypergraph": "workspace:*", "@graphprotocol/hypergraph-react": "workspace:*", "next": "15.3.2", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { "@types/node": "^22", diff --git a/apps/server/package.json b/apps/server/package.json index d18f23b8..0d8c7038 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,7 +19,7 @@ "@privy-io/server-auth": "^1.26.0", "body-parser": "^2.2.0", "cors": "^2.8.5", - "effect": "^3.17.3", + "effect": "^3.17.6", "express": "^5.1.0", "prisma": "^6.7.0", "siwe": "^3.0.0", @@ -29,7 +29,7 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.1", - "@types/node": "^24.1.0", + "@types/node": "^24.2.0", "@types/pg": "^8.15.0", "@types/ws": "^8.18.1", "tsup": "^8.4.0", diff --git a/biome.jsonc b/biome.jsonc index 002e4e30..62fad3d0 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -17,7 +17,8 @@ "!**/tsconfig.*.json", "!**/variant-schema.ts", "!**/apps/create-hypergraph/template-*/**", - "!**/*.css" + "!**/*.css", + "!packages/hypergraph/typesync-studio/src/generated/**/*.ts" ] }, "formatter": { diff --git a/docs/package.json b/docs/package.json index 041af602..b5f7edbc 100644 --- a/docs/package.json +++ b/docs/package.json @@ -19,8 +19,8 @@ "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.8.1", diff --git a/packages/hypergraph-react/package.json b/packages/hypergraph-react/package.json index 2b4b1551..254926f3 100644 --- a/packages/hypergraph-react/package.json +++ b/packages/hypergraph-react/package.json @@ -37,20 +37,20 @@ "@graphprotocol/hypergraph": "workspace:*", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", - "@types/react": "^19.1.3", - "@vitejs/plugin-react": "^4.4.1", + "@types/react": "^19.1.9", + "@vitejs/plugin-react": "^4.7.0", "@xstate/store": "^3.5.1", "jsdom": "^26.1.0", - "react": "^19.1.0" + "react": "^19.1.1" }, "dependencies": { - "@automerge/automerge": "^2.2.9", - "@automerge/automerge-repo": "^2.0.6", - "@automerge/automerge-repo-react-hooks": "^2.0.6", + "@automerge/automerge": "^3.1.1", + "@automerge/automerge-repo": "^2.2.0", + "@automerge/automerge-repo-react-hooks": "^2.2.0", "@graphprotocol/grc-20": "^0.24.1", "@noble/hashes": "^1.8.0", "@tanstack/react-query": "^5.75.5", - "effect": "^3.17.3", + "effect": "^3.17.6", "graphql-request": "^7.1.2", "siwe": "^3.0.0", "uuid": "^11.1.0", diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index 05d477d1..de57145c 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -1,9 +1,7 @@ 'use client'; -// @ts-expect-error not properly typed and exported in the automerge package -import { automergeWasmBase64 } from '@automerge/automerge/automerge.wasm.base64.js'; +import { automergeWasmBase64 } from '@automerge/automerge/automerge.wasm.base64'; import * as automerge from '@automerge/automerge/slim'; -import { uuid } from '@automerge/automerge/slim'; import type { DocHandle } from '@automerge/automerge-repo'; import { Repo } from '@automerge/automerge-repo/slim'; import { RepoContext } from '@automerge/automerge-repo-react-hooks'; @@ -36,6 +34,7 @@ import { useRef, useState, } from 'react'; +import { v4 as uuid } from 'uuid'; import type { Address, Hex } from 'viem'; const decodeResponseMessage = Schema.decodeUnknownEither(Messages.ResponseMessage); diff --git a/packages/hypergraph/package.json b/packages/hypergraph/package.json index 5d2d817a..7ba09a77 100644 --- a/packages/hypergraph/package.json +++ b/packages/hypergraph/package.json @@ -1,11 +1,11 @@ { "name": "@graphprotocol/hypergraph", - "version": "0.5.0", + "version": "0.5.0-alpha.3", "description": "SDK for building performant, type-safe, local-first dapps on top of The Graph ecosystem knowledge graphs.", "publishConfig": { "bin": { - "hypergraph": "./dist/cli/bin.js", - "hg": "./dist/cli/bin.js" + "hypergraph": "./dist/cli/bun.js", + "hg": "./dist/cli/bun.js" }, "access": "public", "directory": "publish", @@ -36,33 +36,41 @@ "./space-info": "./dist/space-info/index.js", "./store": "./dist/store.js", "./store-connect": "./dist/store-connect.js", - "./types": "./dist/types.js" + "./types": "./dist/types.js", + "./typesync": "./dist/cli/services/Model.js" }, "sideEffects": [], "scripts": { - "build": "tsc -b --force tsconfig.build.json && babel dist --plugins annotate-pure-calls --out-dir dist --source-maps && node ../../scripts/package.mjs", + "build": "tsc -b --force tsconfig.build.json && babel dist --plugins annotate-pure-calls --out-dir dist --source-maps && pnpm run prepare-typesync-studio && node ../../scripts/package.mjs", + "build:hypergraph": "tsc -b --force tsconfig.build.json && babel dist --plugins annotate-pure-calls --out-dir dist --source-maps && node ../../scripts/package.mjs", + "build:typesync-studio": "cd ./typesync-studio && pnpm run build", + "copy-typesync-studio-dist": "tsx scripts/copy-typesync-studio-dist.ts", + "prepare-typesync-studio": "pnpm run build:typesync-studio && pnpm run copy-typesync-studio-dist", "test": "vitest", - "hypergraph": "pnpm tsx ./src/cli/bun.ts" + "hypergraph": "node ./dist/cli/bun.js", + "hypergraph:dev": "pnpx tsx ./src/cli/bun.ts" }, "bin": { "hypergraph": "./src/cli/bun.ts", "hg": "./src/cli/bun.ts" }, "devDependencies": { - "@effect/cli": "^0.69.0", - "@effect/platform": "^0.90.0", - "@effect/platform-node": "^0.94.0", - "@effect/printer-ansi": "^0.45.0", "@effect/vitest": "^0.25.0", - "@types/node": "^24.1.0", + "@types/node": "^24.2.0", "@types/uuid": "^10.0.0", "jiti": "^2.5.1", + "tsx": "^4.20.3", "typescript": "^5.8.3" }, "dependencies": { - "@automerge/automerge": "^2.2.9", - "@automerge/automerge-repo": "^2.0.6", - "@effect/experimental": "^0.51.1", + "@automerge/automerge": "^3.1.1", + "@automerge/automerge-repo": "^2.2.0", + "@effect/cli": "^0.69.0", + "@effect/experimental": "^0.54.3", + "@effect/platform": "^0.90.0", + "@effect/platform-node": "^0.94.1", + "@effect/printer": "^0.45.0", + "@effect/printer-ansi": "^0.45.0", "@graphprotocol/grc-20": "^0.24.1", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.0", @@ -72,7 +80,8 @@ "@serenity-kit/noble-sodium": "^0.2.1", "@xstate/store": "^3.5.1", "bs58check": "^4.0.0", - "effect": "^3.17.3", + "effect": "^3.17.6", + "open": "^10.2.0", "permissionless": "^0.2.47", "siwe": "^3.0.0", "uuid": "^11.1.0", diff --git a/packages/hypergraph/scripts/copy-typesync-studio-dist.ts b/packages/hypergraph/scripts/copy-typesync-studio-dist.ts new file mode 100644 index 00000000..eb6cbfa6 --- /dev/null +++ b/packages/hypergraph/scripts/copy-typesync-studio-dist.ts @@ -0,0 +1,19 @@ +import { FileSystem, Path } from '@effect/platform'; +import { NodeContext } from '@effect/platform-node'; +import { Effect } from 'effect'; + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const src = path.resolve('./', 'typesync-studio', 'dist'); + const dest = path.resolve('./', 'dist', 'typesync-studio', 'dist'); + + yield* fs + .makeDirectory(dest, { recursive: true }) + .pipe(Effect.andThen(() => fs.copy(src, dest, { overwrite: true }))); + + return yield* Effect.logInfo('[Build] Copied typesync-studio/dist to dist/typesync-studio/dist'); +}).pipe(Effect.provide(NodeContext.layer)); + +Effect.runPromise(program).catch(console.error); diff --git a/packages/hypergraph/src/cli/Cli.ts b/packages/hypergraph/src/cli/Cli.ts index 6ba0cdeb..83725909 100644 --- a/packages/hypergraph/src/cli/Cli.ts +++ b/packages/hypergraph/src/cli/Cli.ts @@ -11,5 +11,5 @@ const hypergraph = Command.make('hypergraph').pipe( export const run = Command.run(hypergraph, { name: 'hypergraph', - version: '0.3.0', + version: '0.6.0', }); diff --git a/packages/hypergraph/src/cli/bin.ts b/packages/hypergraph/src/cli/bin.ts index c7c230cd..096000e8 100644 --- a/packages/hypergraph/src/cli/bin.ts +++ b/packages/hypergraph/src/cli/bin.ts @@ -6,7 +6,6 @@ import * as AnsiDoc from '@effect/printer-ansi/AnsiDoc'; import * as Effect from 'effect/Effect'; import * as Logger from 'effect/Logger'; import * as LogLevel from 'effect/LogLevel'; - import { run } from './Cli.js'; import { AnsiDocLogger } from './Logger.js'; diff --git a/packages/hypergraph/src/cli/services/Model.ts b/packages/hypergraph/src/cli/services/Model.ts new file mode 100644 index 00000000..ab388691 --- /dev/null +++ b/packages/hypergraph/src/cli/services/Model.ts @@ -0,0 +1,87 @@ +import { Schema } from 'effect'; + +import * as Mapping from '../../mapping/Mapping.js'; +import * as Utils from '../../mapping/Utils.js'; + +export const TypesyncHypergraphSchemaStatus = Schema.NullOr( + Schema.Literal( + // the type/property has been synced to the schema file and published to the Knowledge Graph + 'published', + // the type/property has been synced to the schema file, but requires publishing to the Knowledge Graph + 'synced', + // the type/property exists in the Knowledge Graph, has been added to the users schema through the Typesync UI, but requires syncing to the schema file + 'published_not_synced', + ), +); +export type TypesyncHypergraphSchemaStatus = typeof TypesyncHypergraphSchemaStatus.Type; + +export const TypesyncHypergraphSchemaTypeProperty = Schema.Union( + Mapping.SchemaTypePropertyPrimitive, + Mapping.SchemaTypePropertyRelation, +).pipe( + Schema.extend( + Schema.Struct({ + status: TypesyncHypergraphSchemaStatus, + }), + ), +); +export type TypesyncHypergraphSchemaTypeProperty = typeof TypesyncHypergraphSchemaTypeProperty.Type; +export class TypesyncHypergraphSchemaType extends Schema.Class( + '/Hypergraph/cli/models/TypesyncHypergraphSchemaType', +)({ + ...Mapping.SchemaType.omit('properties').fields, + status: TypesyncHypergraphSchemaStatus, + properties: Schema.Array(TypesyncHypergraphSchemaTypeProperty).pipe( + Schema.minItems(1), + Schema.filter(Utils.namesAreUnique, { + identifier: 'DuplicatePropertyNames', + jsonSchema: {}, + description: 'The property.name must be unique across all properties in the type', + }), + ), +}) {} +export class TypesyncHypergraphSchema extends Schema.Class( + '/Hypergraph/cli/models/TypesyncHypergraphSchema', +)({ + types: Schema.Array(TypesyncHypergraphSchemaType).pipe( + Schema.minItems(1), + Schema.filter(Utils.namesAreUnique, { + identifier: 'DuplicateTypeNames', + jsonSchema: {}, + description: 'The type.name must be unique across all types in the schema', + }), + Schema.filter(Mapping.allRelationPropertyTypesExist, { + identifier: 'AllRelationTypesExist', + jsonSchema: {}, + description: 'Each type property of dataType RELATION must have a type of the same name in the schema', + }), + ), +}) {} + +/** + * Extending the hypergraph [Mapping definition](../../mapping/Mapping.ts) to make it an effect Schema instance. + * Allows decoding as well as passing in the api request payload + */ +export const TypesyncHypergraphMapping = Schema.Record({ + key: Schema.NonEmptyTrimmedString, + value: Schema.Struct({ + typeIds: Schema.Array(Schema.UUID).pipe(Schema.minItems(1)), + properties: Schema.optional( + Schema.UndefinedOr( + Schema.Record({ + key: Schema.NonEmptyTrimmedString, + value: Schema.UUID, + }), + ), + ), + relations: Schema.optional( + Schema.UndefinedOr( + Schema.Record({ + key: Schema.NonEmptyTrimmedString, + value: Schema.UUID, + }), + ), + ), + }), +}); +export type TypesyncHypergraphMapping = typeof TypesyncHypergraphMapping.Type; diff --git a/packages/hypergraph/src/cli/services/Typesync.ts b/packages/hypergraph/src/cli/services/Typesync.ts index 55be0a5e..0798e9e6 100644 --- a/packages/hypergraph/src/cli/services/Typesync.ts +++ b/packages/hypergraph/src/cli/services/Typesync.ts @@ -1,21 +1,32 @@ -import { FileSystem, Path } from '@effect/platform'; +import { FileSystem, KeyValueStore, Path } from '@effect/platform'; import { NodeFileSystem } from '@effect/platform-node'; import { AnsiDoc } from '@effect/printer-ansi'; import { Cause, Data, Effect, Array as EffectArray, Option, Stream } from 'effect'; import type { NonEmptyReadonlyArray } from 'effect/Array'; -import type { Schema as HypergraphSchema, Mapping } from '../../mapping/Mapping.js'; -import { parseHypergraphMapping, parseSchema } from './Utils.js'; +import { type Mapping, propertyIsRelation } from '../../mapping/Mapping.js'; +import { toCamelCase, toPascalCase } from '../../mapping/Utils.js'; +import { + type TypesyncHypergraphMapping, + TypesyncHypergraphSchema, + TypesyncHypergraphSchemaType, + type TypesyncHypergraphSchemaTypeProperty, +} from './Model.js'; +import { buildMappingFile, buildSchemaFile, parseHypergraphMapping, parseSchema } from './Utils.js'; export class TypesyncSchemaStreamBuilder extends Effect.Service()( '/Hypergraph/cli/services/TypesyncSchemaStreamBuilder', { - dependencies: [NodeFileSystem.layer], + dependencies: [NodeFileSystem.layer, KeyValueStore.layerMemory], effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const kv = yield* KeyValueStore.KeyValueStore; const encoder = new TextEncoder(); + const SCHEMA_FILE_PATH_STORAGE_KEY = 'SCHEMA_FILE_PATH'; + const MAPPING_FILE_PATH_STORAGE_KEY = 'MAPPING_FILE_PATH'; + const schemaCandidates = (cwd = '.') => EffectArray.make( path.resolve(cwd, 'schema.ts'), @@ -81,7 +92,7 @@ export class TypesyncSchemaStreamBuilder extends Effect.Service, mappingFilePath: Option.Option, - ): Stream.Stream => + ): Stream.Stream => Stream.fromEffect( Effect.gen(function* () { const schema = yield* Option.match(schemaFilePath, { @@ -102,7 +113,7 @@ export class TypesyncSchemaStreamBuilder extends Effect.Service ({ types: [] }) satisfies HypergraphSchema), + Stream.orElseSucceed(() => ({ types: [] }) satisfies TypesyncHypergraphSchema), ); /** * Reads the schema.ts file, and maybe reads the mapping.ts file (if exists). @@ -117,7 +128,7 @@ export class TypesyncSchemaStreamBuilder extends Effect.Service, mappingFilePath: Option.Option, - ): Stream.Stream => { + ): Stream.Stream => { const schemaWatch = Option.match(schemaFilePath, { // @todo watch the root here so if a schema is created, it will get picked up onNone: () => Stream.empty, @@ -147,7 +158,7 @@ export class TypesyncSchemaStreamBuilder extends Effect.Service ({ types: [] }) satisfies HypergraphSchema), + Stream.orElseSucceed(() => ({ types: [] }) satisfies TypesyncHypergraphSchema), ); }; @@ -162,10 +173,17 @@ export class TypesyncSchemaStreamBuilder extends Effect.Service AnsiDoc.text(candidate))), ); + } else if (Option.isSome(schemaFilePath)) { + // store schema file location in KeyValueStore for reference + yield* kv.set(SCHEMA_FILE_PATH_STORAGE_KEY, schemaFilePath.value); } // Fetch the Mapping definition from any mapping.ts in the directory. // If exists, use it to get the knowledgeGraphId for each type/property in the parsed schema const mappingFilePath = yield* findHypergraphSchema(mappingCandidates(cwd)); + if (Option.isSome(mappingFilePath)) { + // store mapping file location in KeyValueStore for reference + yield* kv.set(MAPPING_FILE_PATH_STORAGE_KEY, mappingFilePath.value); + } return currentSchemaStream(schemaFilePath, mappingFilePath).pipe( Stream.concat(watchSchemaStream(schemaFilePath, mappingFilePath)), @@ -177,7 +195,122 @@ export class TypesyncSchemaStreamBuilder extends Effect.Service + Effect.gen(function* () { + const cwd = process.cwd(); + + const schemaFilePath = yield* kv + .get(SCHEMA_FILE_PATH_STORAGE_KEY) + .pipe(Effect.map(Option.getOrElse(() => path.join(cwd, 'src', 'schema.ts')))); + // update schema file with updated content from the typesync studio UI + yield* fs.writeFileString(schemaFilePath, buildSchemaFile(schema)); + + return TypesyncHypergraphSchema.make({ + types: EffectArray.map(schema.types, (type) => + TypesyncHypergraphSchemaType.make({ + name: type.name, + knowledgeGraphId: type.knowledgeGraphId, + status: type.knowledgeGraphId != null ? 'published' : 'synced', + properties: EffectArray.map(type.properties, (prop) => { + if (propertyIsRelation(prop)) { + return { + name: prop.name, + knowledgeGraphId: prop.knowledgeGraphId, + dataType: prop.dataType, + relationType: prop.relationType, + status: prop.knowledgeGraphId != null ? 'published' : 'synced', + } satisfies TypesyncHypergraphSchemaTypeProperty; + } + + return { + name: prop.name, + knowledgeGraphId: prop.knowledgeGraphId, + dataType: prop.dataType, + status: prop.knowledgeGraphId != null ? 'published' : 'synced', + } satisfies TypesyncHypergraphSchemaTypeProperty; + }), + }), + ), + }); + }); + + /** + * Update the mapping.ts file in the users repo with the up-to-date, published to the Knowledge Graph, mapping + * + * @param schema the Hypergraph schema + * @param mapping the up-to-date Hypergraph Mapping with all types/properties having Id + * @returns the updated schema with connected knowledgeGraphIds + */ + const syncMapping = (schema: TypesyncHypergraphSchema, mapping: TypesyncHypergraphMapping) => + Effect.gen(function* () { + const cwd = process.cwd(); + + const mappingFilePath = yield* kv + .get(MAPPING_FILE_PATH_STORAGE_KEY) + .pipe(Effect.map(Option.getOrElse(() => path.join(cwd, 'src', 'mapping.ts')))); + // update mapping file with updated content from the typesync studio UI + yield* fs.writeFileString(mappingFilePath, buildMappingFile(mapping)); + + // update Schema to update with generated GRC-20 Ids for types/properties + const updatedSchema = TypesyncHypergraphSchema.make({ + types: EffectArray.map(schema.types, (type) => { + const mappingEntry = mapping[toPascalCase(type.name)]; + + let knowledgeGraphId = type.knowledgeGraphId; + if (!knowledgeGraphId) { + const typeKnowledgeGraphId = mappingEntry?.typeIds?.[0] ? mappingEntry.typeIds[0] : null; + if (typeKnowledgeGraphId) { + knowledgeGraphId = typeKnowledgeGraphId; + } + } + + return TypesyncHypergraphSchemaType.make({ + name: type.name, + knowledgeGraphId, + status: knowledgeGraphId != null ? 'published' : 'synced', + properties: EffectArray.map(type.properties, (prop) => { + const propName = toCamelCase(prop.name); + + if (propertyIsRelation(prop)) { + const relKnowledgeGraphId = prop.knowledgeGraphId || mappingEntry?.relations?.[propName] || null; + return { + name: prop.name, + knowledgeGraphId: relKnowledgeGraphId, + dataType: prop.dataType, + relationType: prop.relationType, + status: relKnowledgeGraphId != null ? 'published' : 'synced', + } satisfies TypesyncHypergraphSchemaTypeProperty; + } + + const propKnowledgeGraphId = prop.knowledgeGraphId || mappingEntry?.properties?.[propName] || null; + return { + name: prop.name, + knowledgeGraphId: propKnowledgeGraphId, + dataType: prop.dataType, + status: propKnowledgeGraphId != null ? 'published' : 'synced', + } satisfies TypesyncHypergraphSchemaTypeProperty; + }), + }); + }), + }); + + // sync the schema to the schema.ts file + yield* syncSchema(updatedSchema); + + return updatedSchema; + }); + + return { + hypergraphSchemaStream, + syncSchema, + syncMapping, + } as const; }), }, ) {} diff --git a/packages/hypergraph/src/cli/services/Utils.ts b/packages/hypergraph/src/cli/services/Utils.ts index f0a794a5..a496e7fb 100644 --- a/packages/hypergraph/src/cli/services/Utils.ts +++ b/packages/hypergraph/src/cli/services/Utils.ts @@ -1,8 +1,10 @@ +import { Doc } from '@effect/printer'; import { Data, Effect, Array as EffectArray } from 'effect'; import ts from 'typescript'; import * as Mapping from '../../mapping/Mapping.js'; import * as Utils from '../../mapping/Utils.js'; +import type * as Model from './Model.js'; /** * 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'; export const parseSchema = ( sourceCode: string, mapping: Mapping.Mapping, -): Effect.Effect => +): Effect.Effect => Effect.try({ try() { const sourceFile = ts.createSourceFile('schema.ts', sourceCode, ts.ScriptTarget.Latest, true); - const entities: Array = []; + const entities: Array = []; const visit = (node: ts.Node) => { if (ts.isClassDeclaration(node) && node.name) { const className = node.name.text; - const properties: Array = []; + const properties: Array = []; // Find the Entity.Class call if (node.heritageClauses) { @@ -44,14 +46,29 @@ export const parseSchema = ( const propName = prop.name.text; let dataType: Mapping.SchemaDataType = 'String'; let relationType: string | undefined; + let isOptional = false; const mappingEntry = mapping[className]; const camelCasePropName = Utils.toCamelCase(propName); + // Check if the property is wrapped with Type.optional() + let typeExpression = prop.initializer; + if ( + ts.isCallExpression(typeExpression) && + ts.isPropertyAccessExpression(typeExpression.expression) && + typeExpression.expression.name.text === 'optional' + ) { + isOptional = true; + // Unwrap the optional to get the actual type + if (typeExpression.arguments.length > 0) { + typeExpression = typeExpression.arguments[0]; + } + } + // Extract the type - if (ts.isPropertyAccessExpression(prop.initializer)) { - // Simple types like Type.Text - dataType = Mapping.getDataType(prop.initializer.name.text); + if (ts.isPropertyAccessExpression(typeExpression)) { + // Simple types like Type.String + dataType = Mapping.getDataType(typeExpression.name.text); // Look up the property's knowledgeGraphId from the mapping const propKnowledgeGraphId = mappingEntry?.properties?.[camelCasePropName] || null; @@ -61,10 +78,12 @@ export const parseSchema = ( name: propName, dataType: dataType as Mapping.SchemaDataTypePrimitive, knowledgeGraphId: propKnowledgeGraphId, - } satisfies Mapping.SchemaTypePropertyPrimitive); - } else if (ts.isCallExpression(prop.initializer)) { + optional: isOptional || undefined, + status: propKnowledgeGraphId != null ? 'published' : 'synced', + } satisfies Model.TypesyncHypergraphSchemaTypeProperty); + } else if (ts.isCallExpression(typeExpression)) { // Relation types like Type.Relation(User) - const callNode = prop.initializer; + const callNode = typeExpression; if (ts.isPropertyAccessExpression(callNode.expression)) { const typeName = callNode.expression.name.text; @@ -83,7 +102,9 @@ export const parseSchema = ( dataType, relationType, knowledgeGraphId: relKnowledgeGraphId, - } satisfies Mapping.SchemaTypePropertyRelation); + optional: isOptional || undefined, + status: relKnowledgeGraphId != null ? 'published' : 'synced', + } satisfies Model.TypesyncHypergraphSchemaTypeProperty); } } } @@ -101,7 +122,12 @@ export const parseSchema = ( const mappingEntry = mapping[Utils.toPascalCase(className)]; const typeKnowledgeGraphId = mappingEntry?.typeIds?.[0] ? mappingEntry.typeIds[0] : null; - entities.push({ name: className, knowledgeGraphId: typeKnowledgeGraphId, properties }); + entities.push({ + name: className, + knowledgeGraphId: typeKnowledgeGraphId, + properties, + status: typeKnowledgeGraphId != null ? 'published' : 'synced', + }); } ts.forEachChild(node, visit); @@ -185,3 +211,197 @@ export function parseHypergraphMapping(moduleExport: any): Mapping.Mapping { // If no preferred names found, use the first one return mappingCandidates[0][1] as Mapping.Mapping; } + +function fieldToEntityString({ name, dataType, optional = false }: Model.TypesyncHypergraphSchemaTypeProperty): string { + // Convert type to Entity type + const entityType = (() => { + switch (true) { + case dataType === 'String': + return 'Type.String'; + case dataType === 'Number': + return 'Type.Number'; + case dataType === 'Boolean': + return 'Type.Boolean'; + case dataType === 'Date': + return 'Type.Date'; + case dataType === 'Point': + return 'Type.Point'; + case Mapping.isDataTypeRelation(dataType): + // renders the type as `Type.Relation(Entity)` + return `Type.${dataType}`; + default: + // how to handle complex types + return 'Type.String'; + } + })(); + + if (optional === true) { + return ` ${Utils.toCamelCase(name)}: Type.optional(${entityType})`; + } + + // adds a tab before the property + return ` ${Utils.toCamelCase(name)}: ${entityType}`; +} + +function typeDefinitionToString(type: Model.TypesyncHypergraphSchemaType): string | null { + if (!type.name) { + return null; + } + const fields = type.properties.filter((_prop) => _prop.name != null && _prop.name.length > 0); + if (fields.length === 0) { + return null; + } + + const fieldStrings = fields.map(fieldToEntityString); + + const name = Utils.toPascalCase(type.name); + return `export class ${name} extends Entity.Class<${name}>('${name}')({ +${fieldStrings.join(',\n')} +}) {}`; +} + +/** + * Builds a string of the schema.ts file contents after parsing the schema into the correct format. + * + * @example + * + * ```typescript + * const schema = Model.TypesyncHypergraphSchema.make({ + * types: [ + * { + * name: "User", + * knowledgeGraphId: null, + * status: null, + * properties: [ + * { + * name: "name", + * dataType: "String", + * knowledgeGraphId: null, + * optional: null, + * status: null + * } + * ] + * } + * ] + * }) + * const schemaFile = buildSchemaFile(schema) + * + * expect(schemaFile).toEqual(` + * import { Entity, Type } from '@graphprotocol/hypergraph'; + * + * export class User extends Entity.Class('User')({ + * name: Type.String + * }) {} + * `) + * ``` + */ +export function buildSchemaFile(schema: Model.TypesyncHypergraphSchema) { + const importStatement = `import { Entity, Type } from '@graphprotocol/hypergraph';`; + + const typeDefinitions = schema.types + .map(typeDefinitionToString) + .filter((def) => def != null) + .join('\n\n'); + return [importStatement, typeDefinitions].join('\n\n'); +} + +export function buildMappingFile(mapping: Mapping.Mapping | Model.TypesyncHypergraphMapping) { + // Import statements + const imports = Doc.vsep([ + Doc.text("import type { Mapping } from '@graphprotocol/hypergraph/mapping';"), + Doc.text("import { Id } from '@graphprotocol/hypergraph';"), + ]); + + // Generate the mapping object - build it line by line for exact formatting + const mappingLines = [Doc.text('export const mapping: Mapping = {')]; + + for (const [typeName, typeData] of Object.entries(mapping)) { + mappingLines.push(Doc.text(` ${typeName}: {`)); + + // Type IDs + const typeIdsList = typeData.typeIds.map((id: string) => `Id("${id}")`).join(', '); + mappingLines.push(Doc.text(` typeIds: [${typeIdsList}],`)); + + // Properties + const properties = Object.entries(typeData.properties ?? {}); + if (EffectArray.isNonEmptyArray(properties)) { + mappingLines.push(Doc.text(' properties: {')); + properties.forEach(([propName, propId], index, entries) => { + const isLast = index === entries.length - 1; + const comma = isLast ? '' : ','; + mappingLines.push(Doc.text(` ${propName}: Id("${propId}")${comma}`)); + }); + mappingLines.push(Doc.text(' },')); + } + + // Relations + const relations = Object.entries(typeData.relations ?? {}); + if (EffectArray.isNonEmptyArray(relations)) { + mappingLines.push(Doc.text(' relations: {')); + relations.forEach(([relationName, relationId], index, entries) => { + const isLast = index === entries.length - 1; + const comma = isLast ? '' : ','; + mappingLines.push(Doc.text(` ${relationName}: Id("${relationId}")${comma}`)); + }); + mappingLines.push(Doc.text(' },')); + } + + mappingLines.push(Doc.text(' },')); + } + + mappingLines.push(Doc.rbrace); + + const compiled = Doc.vcat([imports, Doc.empty, ...mappingLines]); + + return Doc.render(compiled, { + style: 'pretty', + options: { lineWidth: 120 }, + }); +} + +/** + * Builds a string of the mapping.ts file contents after parsing the schema into the correct mapping format. + * + * @example + * + * ```typescript + * const schema = Model.TypesyncHypergraphSchema.make({ + * types: [ + * { + * name: "User", + * knowledgeGraphId: "7f9562d4-034d-4385-bf5c-f02cdebba47a", + * status: null, + * properties: [ + * { + * name: "name", + * dataType: "String", + * knowledgeGraphId: "a126ca53-0c8e-48d5-b888-82c734c38935", + * optional: null, + * status: null + * } + * ] + * } + * ] + * }) + * const mappingFile = buildMappingFile(schema) + * + * expect(mappingFile).toEqual(` + * import type { Mapping } from '@graphprotocol/hypergraph/mapping'; + * import { Id } from '@graphprotocol/hypergraph'; + * + * export const mapping: Mapping = { + * User: { + * typeIds: [Id('7f9562d4-034d-4385-bf5c-f02cdebba47a')], + * properties: { + * name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + * } + * } + * } + * `) + * ``` + */ +export function buildMappingFileFromSchema(schema: Model.TypesyncHypergraphSchema) { + const [mapping] = Mapping.generateMapping(schema); + + return buildMappingFile(mapping); +} diff --git a/packages/hypergraph/src/cli/subcommands/typesync.ts b/packages/hypergraph/src/cli/subcommands/typesync.ts index e3e26a18..25c3a42b 100644 --- a/packages/hypergraph/src/cli/subcommands/typesync.ts +++ b/packages/hypergraph/src/cli/subcommands/typesync.ts @@ -1,6 +1,8 @@ import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; import { Command, Options } from '@effect/cli'; import { + FileSystem, HttpApi, HttpApiBuilder, HttpApiEndpoint, @@ -8,70 +10,265 @@ import { HttpApiGroup, HttpApiSchema, HttpMiddleware, + HttpRouter, HttpServer, HttpServerResponse, + Path, } from '@effect/platform'; import { NodeHttpServer } from '@effect/platform-node'; import { AnsiDoc } from '@effect/printer-ansi'; -import { Effect, Layer, Schema } from 'effect'; +import { Cause, Data, Effect, Layer, Option, Schema, Struct } from 'effect'; +import open, { type AppName, apps } from 'open'; +import * as Model from '../services/Model.js'; import * as Typesync from '../services/Typesync.js'; -const hypergraphTypeSyncApi = HttpApi.make('HypergraphTypeSyncApi') +class HypergraphTypesyncStudioApiRouter extends HttpApiGroup.make('HypergraphTypesyncStudioApiRouter') .add( - HttpApiGroup.make('SchemaStreamGroup') - .add( - // 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 - HttpApiEndpoint.get('HypergraphSchemaEventStream')`/schema/events` - .addError(HttpApiError.InternalServerError) - .addSuccess( - Schema.String.pipe( - HttpApiSchema.withEncoding({ - kind: 'Json', - contentType: 'text/event-stream', - }), - ), - ), + // 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 + HttpApiEndpoint.get('HypergraphSchemaEventStream')`/schema/events` + .addError(HttpApiError.InternalServerError) + .addSuccess( + Schema.String.pipe( + HttpApiSchema.withEncoding({ + kind: 'Json', + contentType: 'text/event-stream', + }), + ), + ), + ) + .add( + HttpApiEndpoint.post('SyncHypergraphSchema')`/schema/sync` + .setPayload(Model.TypesyncHypergraphSchema) + .addSuccess(Model.TypesyncHypergraphSchema) + .addError(HttpApiError.InternalServerError) + .addError(HttpApiError.BadRequest), + ) + .add( + HttpApiEndpoint.post('SyncHypergraphMapping')`/mapping/sync` + .setPayload( + Schema.Struct({ + schema: Model.TypesyncHypergraphSchema, + mapping: Model.TypesyncHypergraphMapping, + }), ) - .prefix('/v1'), + .addSuccess(Model.TypesyncHypergraphSchema) + .addError(HttpApiError.InternalServerError) + .addError(HttpApiError.BadRequest), ) - .prefix('/api'); + .prefix('/v1') {} +class HypergraphTypesyncStudioApi extends HttpApi.make('HypergraphTypesyncStudioApi') + .add(HypergraphTypesyncStudioApiRouter) + .prefix('/api') {} -const hypergraphTypeSyncApiLive = HttpApiBuilder.group(hypergraphTypeSyncApi, 'SchemaStreamGroup', (handlers) => - handlers.handle('HypergraphSchemaEventStream', () => +const hypergraphTypeSyncApiLive = HttpApiBuilder.group( + HypergraphTypesyncStudioApi, + 'HypergraphTypesyncStudioApiRouter', + (handlers) => Effect.gen(function* () { const schemaStream = yield* Typesync.TypesyncSchemaStreamBuilder; - const stream = yield* schemaStream - .hypergraphSchemaStream() - .pipe(Effect.catchAll(() => new HttpApiError.InternalServerError())); + return handlers + .handle('HypergraphSchemaEventStream', () => + Effect.gen(function* () { + const stream = yield* schemaStream.hypergraphSchemaStream().pipe( + Effect.tapErrorCause((cause) => + Effect.logError( + AnsiDoc.cat( + AnsiDoc.text('Failure building Hypergraph events stream:'), + AnsiDoc.text(Cause.pretty(cause)), + ), + ), + ), + Effect.catchAll(() => new HttpApiError.InternalServerError()), + ); - return yield* HttpServerResponse.stream(stream, { contentType: 'text/event-stream' }).pipe( - HttpServerResponse.setHeaders({ - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }), - ); + return yield* HttpServerResponse.stream(stream, { contentType: 'text/event-stream' }).pipe( + HttpServerResponse.setHeaders({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }), + ); + }), + ) + .handle('SyncHypergraphSchema', ({ payload }) => + schemaStream.syncSchema(payload).pipe( + Effect.tapErrorCause((cause) => + Effect.logError( + AnsiDoc.cat(AnsiDoc.text('Failure syncing Hypergraph Schema:'), AnsiDoc.text(Cause.pretty(cause))), + ), + ), + Effect.catchAll(() => new HttpApiError.InternalServerError()), + ), + ) + .handle('SyncHypergraphMapping', ({ payload }) => + schemaStream.syncMapping(payload.schema, payload.mapping).pipe( + Effect.tapErrorCause((cause) => + Effect.logError( + AnsiDoc.cat(AnsiDoc.text('Failure syncing Hypergraph mapping:'), AnsiDoc.text(Cause.pretty(cause))), + ), + ), + Effect.catchAll(() => new HttpApiError.InternalServerError()), + ), + ); }), - ), ); -const HypergraphTypeSyncApiLive = HttpApiBuilder.api(hypergraphTypeSyncApi).pipe( - Layer.provide(hypergraphTypeSyncApiLive), +const HypergraphTypeSyncApiLayer = HttpApiBuilder.middlewareCors({ + allowedMethods: ['GET', 'POST', 'OPTIONS'], + allowedOrigins: ['http://localhost:3000', 'http://localhost:5173'], +}).pipe( + Layer.provideMerge(HttpApiBuilder.api(HypergraphTypesyncStudioApi)), Layer.provide(Typesync.layer), + Layer.provide(hypergraphTypeSyncApiLive), ); -const HypergraphTypeSyncApiLayer = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( - Layer.provide(HttpApiBuilder.middlewareCors()), - Layer.provide(HypergraphTypeSyncApiLive), +const HypergraphTypeSyncApiLive = HttpApiBuilder.httpApp.pipe( + Effect.provide( + Layer.mergeAll(HypergraphTypeSyncApiLayer, HttpApiBuilder.Router.Live, HttpApiBuilder.Middleware.layer), + ), ); +const TypesyncStudioFileRouter = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + // Try multiple possible locations for the dist directory + const possiblePaths = [ + // npm published package (when this file is in node_modules/@graphprotocol/hypergraph/dist/cli/subcommands/) + path.resolve(__dirname, '..', '..', 'typesync-studio', 'dist'), + // Development mode (when this file is in packages/hypergraph/src/cli/subcommands/) + path.resolve(__dirname, '..', '..', '..', 'typesync-studio', 'dist'), + ]; + + const findTypesyncStudioDist = Effect.fnUntraced(function* () { + return yield* Effect.findFirst(possiblePaths, (_) => fs.exists(_).pipe(Effect.orElseSucceed(() => false))); + }); + + const typesyncStudioClientDist = yield* findTypesyncStudioDist().pipe( + // default to first path + Effect.map((maybe) => Option.getOrElse(maybe, () => possiblePaths[0])), + ); + + return HttpRouter.empty.pipe( + HttpRouter.get( + '/', + HttpServerResponse.file(path.join(typesyncStudioClientDist, 'index.html')).pipe( + Effect.orElse(() => HttpServerResponse.empty({ status: 404 })), + ), + ), + // specific handler for the /authenticate-callback endpoint for auth + HttpRouter.get( + '/authenticate-callback', + HttpServerResponse.file(path.join(typesyncStudioClientDist, 'index.html')).pipe( + Effect.orElse(() => HttpServerResponse.empty({ status: 404 })), + ), + ), + HttpRouter.get( + '/assets/:file', + Effect.gen(function* () { + const file = yield* HttpRouter.params.pipe(Effect.map(Struct.get('file')), Effect.map(Option.fromNullable)); + + if (Option.isNone(file)) { + return HttpServerResponse.empty({ status: 404 }); + } + + const assets = path.join(typesyncStudioClientDist, 'assets'); + const normalized = path.normalize(path.join(assets, ...file.value.split('/'))); + if (!normalized.startsWith(assets)) { + return HttpServerResponse.empty({ status: 404 }); + } + + return yield* HttpServerResponse.file(normalized); + }).pipe(Effect.orElse(() => HttpServerResponse.empty({ status: 404 }))), + ), + ); +}); + +const Server = Effect.all({ + api: HypergraphTypeSyncApiLive, + files: TypesyncStudioFileRouter, +}).pipe( + Effect.map(({ api, files }) => + HttpRouter.empty.pipe(HttpRouter.mount('/', files), HttpRouter.mountApp('/api', api, { includePrefix: true })), + ), + Effect.map((router) => HttpServer.serve(HttpMiddleware.logger)(router)), + Layer.unwrapEffect, +); + +const openBrowser = (port: number, browser: AppName | 'arc' | 'safari' | 'browser' | 'browserPrivate') => + Effect.async((resume) => { + const url = `http://localhost:${port}`; + + const launch = (appOpts?: { name: string | ReadonlyArray }) => + open(url, appOpts ? { app: appOpts } : undefined).then((subprocess) => { + subprocess.on('spawn', () => resume(Effect.void)); + subprocess.on('error', (err) => resume(Effect.fail(new OpenBrowserError({ cause: err })))); + }); + + const mapBrowserName = (b: typeof browser): string | ReadonlyArray | undefined => { + switch (b) { + case 'chrome': + return apps.chrome; // cross-platform alias from open + case 'firefox': + return apps.firefox; + case 'edge': + return apps.edge; + case 'safari': + return 'Safari'; + case 'arc': + return 'Arc'; + default: + return undefined; + } + }; + + switch (browser) { + case 'browser': + launch(); + break; + case 'browserPrivate': + launch({ name: apps.browserPrivate }); + break; + default: { + const mapped = mapBrowserName(browser); + if (mapped) { + launch({ name: mapped }).catch(() => launch()); + break; + } + launch(); + break; + } + } + }); + +export class OpenBrowserError extends Data.TaggedError('Nozzle/cli/studio/errors/OpenBrowserError')<{ + readonly cause: unknown; +}> {} + export const typesync = Command.make('typesync', { args: { - port: Options.integer('port').pipe( - Options.withAlias('p'), - Options.withDefault(3000), - Options.withDescription('The port to run the Hypergraph TypeSync studio server on. Default 3000'), + open: Options.boolean('open').pipe( + Options.withDescription('If true, opens the nozzle dataset studio in your browser'), + Options.withDefault(true), + ), + browser: Options.choice('browser', [ + 'chrome', + 'firefox', + 'edge', + 'safari', + 'arc', + 'browser', + 'browserPrivate', + ]).pipe( + Options.withAlias('b'), + Options.withDescription( + 'Broweser to open the nozzle dataset studio app in. Default is your default selected browser', + ), + Options.withDefault('browser'), ), }, }).pipe( @@ -80,14 +277,33 @@ export const typesync = Command.make('typesync', { ), Command.withHandler(({ args }) => Effect.gen(function* () { - yield* HypergraphTypeSyncApiLayer.pipe( + yield* Server.pipe( HttpServer.withLogAddress, - Layer.provide(NodeHttpServer.layer(createServer, { port: args.port })), + Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), + Layer.tap(() => + Effect.gen(function* () { + if (args.open) { + return yield* openBrowser(3000, args.browser).pipe( + Effect.tapErrorCause((cause) => + Effect.logWarning( + AnsiDoc.text( + 'Failure opening nozzle dataset studio in your browser. Open at http://localhost:3000', + ), + AnsiDoc.text(Cause.pretty(cause)), + ), + ), + Effect.orElseSucceed(() => Effect.void), + ); + } + return Effect.void; + }), + ), Layer.tap(() => - Effect.logInfo(AnsiDoc.text(`🎉 TypeSync studio started and running at http://localhost:${args.port}`)), + Effect.logInfo(AnsiDoc.text('🎉 TypeSync studio started and running at http://localhost:3000')), ), Layer.launch, ); }), ), + Command.provide(Typesync.layer), ); diff --git a/packages/hypergraph/src/index.ts b/packages/hypergraph/src/index.ts index 00fa27a5..1658592c 100644 --- a/packages/hypergraph/src/index.ts +++ b/packages/hypergraph/src/index.ts @@ -1,4 +1,5 @@ export { Id } from '@graphprotocol/grc-20'; +export * as Typesync from './cli/services/Model.js'; export * as Connect from './connect/index.js'; export * as Entity from './entity/index.js'; export * as Identity from './identity/index.js'; diff --git a/packages/hypergraph/src/mapping/Mapping.ts b/packages/hypergraph/src/mapping/Mapping.ts index 451c4679..d042f872 100644 --- a/packages/hypergraph/src/mapping/Mapping.ts +++ b/packages/hypergraph/src/mapping/Mapping.ts @@ -129,12 +129,21 @@ export function getDataType(val: string): SchemaDataType { } throw new Error(`Passed dataType ${val} is not supported`); } + +const BaseSchemaTypeProperty = EffectSchema.Struct({ + name: EffectSchema.NonEmptyTrimmedString, + knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID), + /** + * @since 0.4.0 + */ + optional: EffectSchema.optional(EffectSchema.NullishOr(EffectSchema.Boolean)), +}); + /** * @since 0.2.0 */ export const SchemaTypePropertyRelation = EffectSchema.Struct({ - name: EffectSchema.NonEmptyTrimmedString, - knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID), + ...BaseSchemaTypeProperty.fields, dataType: SchemaDataTypeRelation, relationType: EffectSchema.NonEmptyTrimmedString.annotations({ identifier: 'SchemaTypePropertyRelation.relationType', @@ -150,8 +159,7 @@ export type SchemaTypePropertyRelation = typeof SchemaTypePropertyRelation.Type; * @since 0.2.0 */ export const SchemaTypePropertyPrimitive = EffectSchema.Struct({ - name: EffectSchema.NonEmptyTrimmedString, - knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID), + ...BaseSchemaTypeProperty.fields, dataType: SchemaDataTypePrimitive, }); /** @@ -217,7 +225,7 @@ export const Schema = EffectSchema.Struct({ { name: 'Account', knowledgeGraphId: null, - properties: [{ name: 'username', knowledgeGraphId: null, dataType: 'String' }], + properties: [{ name: 'username', optional: null, knowledgeGraphId: null, dataType: 'String' }], }, ], }, @@ -226,7 +234,14 @@ export const Schema = EffectSchema.Struct({ { name: 'Account', knowledgeGraphId: 'a5fd07b1-120f-46c6-b46f-387ef98396a6', - properties: [{ name: 'name', knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', dataType: 'String' }], + properties: [ + { + name: 'name', + optional: true, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + dataType: 'String', + }, + ], }, ], }, diff --git a/packages/hypergraph/test/cli/services/Utils.test.ts b/packages/hypergraph/test/cli/services/Utils.test.ts index bf1a2021..020ec49e 100644 --- a/packages/hypergraph/test/cli/services/Utils.test.ts +++ b/packages/hypergraph/test/cli/services/Utils.test.ts @@ -1,7 +1,12 @@ import { describe, it } from '@effect/vitest'; import { Id } from '@graphprotocol/grc-20'; import { Effect } from 'effect'; -import { parseHypergraphMapping, parseSchema } from '../../../src/cli/services/Utils.js'; +import { + buildMappingFileFromSchema, + buildSchemaFile, + parseHypergraphMapping, + parseSchema, +} from '../../../src/cli/services/Utils.js'; import type { Mapping } from '../../../src/mapping/Mapping.js'; describe('parseSchema', () => { @@ -73,6 +78,7 @@ export class Event extends Entity.Class('Event')({ name: 'name', dataType: 'String', knowledgeGraphId: null, + status: 'synced', }); // Check Todo entity @@ -83,17 +89,20 @@ export class Event extends Entity.Class('Event')({ name: 'name', dataType: 'String', knowledgeGraphId: null, + status: 'synced', }); expect(todoEntity?.properties[1]).toMatchObject({ name: 'completed', dataType: 'Boolean', knowledgeGraphId: null, + status: 'synced', }); expect(todoEntity?.properties[2]).toMatchObject({ name: 'assignees', dataType: 'Relation(User)', relationType: 'User', knowledgeGraphId: null, + status: 'synced', }); // Check Todo2 entity with various types @@ -104,16 +113,19 @@ export class Event extends Entity.Class('Event')({ name: 'due', dataType: 'Date', knowledgeGraphId: null, + status: 'synced', }); expect(todo2Entity?.properties[4]).toMatchObject({ name: 'amount', dataType: 'Number', knowledgeGraphId: null, + status: 'synced', }); expect(todo2Entity?.properties[5]).toMatchObject({ name: 'point', dataType: 'Point', knowledgeGraphId: null, + status: 'synced', }); // Check Company entity with relation @@ -125,6 +137,7 @@ export class Event extends Entity.Class('Event')({ dataType: 'Relation(JobOffer)', relationType: 'JobOffer', knowledgeGraphId: null, + status: 'synced', }); }); }), @@ -192,12 +205,14 @@ export class JobOffer extends Entity.Class('JobOffer')({ name: 'name', dataType: 'String', knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + status: 'published', }); expect(eventEntity?.properties[1]).toMatchObject({ name: 'sponsors', dataType: 'Relation(Company)', relationType: 'Company', knowledgeGraphId: '6860bfac-f703-4289-b789-972d0aaf3abe', + status: 'published', }); // Check Company entity with resolved IDs @@ -209,12 +224,14 @@ export class JobOffer extends Entity.Class('JobOffer')({ name: 'name', dataType: 'String', knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + status: 'published', }); expect(companyEntity?.properties[1]).toMatchObject({ name: 'jobOffers', dataType: 'Relation(JobOffer)', relationType: 'JobOffer', knowledgeGraphId: '1203064e-9741-4235-89d4-97f4b22eddfb', + status: 'published', }); // Check JobOffer entity with resolved IDs @@ -226,11 +243,116 @@ export class JobOffer extends Entity.Class('JobOffer')({ name: 'name', dataType: 'String', knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + status: 'published', }); expect(jobOfferEntity?.properties[1]).toMatchObject({ name: 'salary', dataType: 'Number', knowledgeGraphId: 'baa36ac9-78ac-4cf7-8394-6b2d3006bebe', + status: 'published', + }); + }); + }), + ); + + it.effect('should parse schema with optional properties', ({ expect }) => + Effect.gen(function* () { + const schemaContent = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class User extends Entity.Class('User')({ + name: Type.String, + email: Type.optional(Type.String), +}) {} + +export class Event extends Entity.Class('Event')({ + name: Type.String, + description: Type.optional(Type.String), + location: Type.optional(Type.Point), + startDate: Type.Date, + endDate: Type.optional(Type.Date), + organizer: Type.Relation(User), + coOrganizers: Type.optional(Type.Relation(User)), +}) {}`; + + const emptyMapping: Mapping = {}; + const result = yield* parseSchema(schemaContent, emptyMapping); + + yield* Effect.sync(() => { + expect(result.types).toHaveLength(2); + + // Check User entity with optional email + const userEntity = result.types.find((t) => t.name === 'User'); + expect(userEntity).toBeDefined(); + expect(userEntity?.properties).toHaveLength(2); + expect(userEntity?.properties[0]).toMatchObject({ + name: 'name', + dataType: 'String', + knowledgeGraphId: null, + }); + expect(userEntity?.properties[0].optional).toBeUndefined(); + expect(userEntity?.properties[1]).toMatchObject({ + name: 'email', + dataType: 'String', + knowledgeGraphId: null, + optional: true, + }); + + // Check Event entity with multiple optional properties + const eventEntity = result.types.find((t) => t.name === 'Event'); + expect(eventEntity).toBeDefined(); + expect(eventEntity?.properties).toHaveLength(7); + + // Required properties + expect(eventEntity?.properties[0]).toMatchObject({ + name: 'name', + dataType: 'String', + knowledgeGraphId: null, + }); + expect(eventEntity?.properties[0].optional).toBeUndefined(); + + expect(eventEntity?.properties[3]).toMatchObject({ + name: 'startDate', + dataType: 'Date', + knowledgeGraphId: null, + }); + expect(eventEntity?.properties[3].optional).toBeUndefined(); + + expect(eventEntity?.properties[5]).toMatchObject({ + name: 'organizer', + dataType: 'Relation(User)', + relationType: 'User', + knowledgeGraphId: null, + }); + expect(eventEntity?.properties[5].optional).toBeUndefined(); + + // Optional properties + expect(eventEntity?.properties[1]).toMatchObject({ + name: 'description', + dataType: 'String', + knowledgeGraphId: null, + optional: true, + }); + + expect(eventEntity?.properties[2]).toMatchObject({ + name: 'location', + dataType: 'Point', + knowledgeGraphId: null, + optional: true, + }); + + expect(eventEntity?.properties[4]).toMatchObject({ + name: 'endDate', + dataType: 'Date', + knowledgeGraphId: null, + optional: true, + }); + + expect(eventEntity?.properties[6]).toMatchObject({ + name: 'coOrganizers', + dataType: 'Relation(User)', + relationType: 'User', + knowledgeGraphId: null, + optional: true, }); }); }), @@ -424,3 +546,1321 @@ describe('parseHypergraphMapping', () => { expect(result).toEqual({}); }); }); + +describe('buildSchemaFile', () => { + it('should build schema file with single entity and single property', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class User extends Entity.Class('User')({ + name: Type.String +}) {}`; + + expect(result).toBe(expected); + }); + + it('should build schema file with multiple entities', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + { + name: 'Post', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'content', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class User extends Entity.Class('User')({ + name: Type.String +}) {} + +export class Post extends Entity.Class('Post')({ + title: Type.String, + content: Type.String +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle all primitive data types', ({ expect }) => { + const schema = { + types: [ + { + name: 'TestEntity', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'text_field', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'numeric_field', + dataType: 'Number' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'boolean_field', + dataType: 'Boolean' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'date_field', + dataType: 'Date' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'location_field', + dataType: 'Point' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class TestEntity extends Entity.Class('TestEntity')({ + textField: Type.String, + numericField: Type.Number, + booleanField: Type.Boolean, + dateField: Type.Date, + locationField: Type.Point +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle optional properties', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'email', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: true, + status: 'synced' as const, + }, + { + name: 'age', + dataType: 'Number' as const, + knowledgeGraphId: null, + optional: true, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class User extends Entity.Class('User')({ + name: Type.String, + email: Type.optional(Type.String), + age: Type.optional(Type.Number) +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle relation properties', ({ expect }) => { + const schema = { + types: [ + { + name: 'Post', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'author', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class Post extends Entity.Class('Post')({ + title: Type.String, + author: Type.Relation(User) +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle optional relation properties', ({ expect }) => { + const schema = { + types: [ + { + name: 'Event', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'organizer', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'co_organizers', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: null, + optional: true, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class Event extends Entity.Class('Event')({ + name: Type.String, + organizer: Type.Relation(User), + coOrganizers: Type.optional(Type.Relation(User)) +}) {}`; + + expect(result).toBe(expected); + }); + + it('should convert snake_case property names to camelCase', ({ expect }) => { + const schema = { + types: [ + { + name: 'Product', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'product_name', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'unit_price', + dataType: 'Number' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'is_available', + dataType: 'Boolean' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class Product extends Entity.Class('Product')({ + productName: Type.String, + unitPrice: Type.Number, + isAvailable: Type.Boolean +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle empty schema', ({ expect }) => { + const schema = { + types: [], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +`; + + expect(result).toBe(expected); + }); + + it('should filter out entities with no name', ({ expect }) => { + const schema = { + types: [ + { + name: '', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'field', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + { + name: 'ValidEntity', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'field', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class ValidEntity extends Entity.Class('ValidEntity')({ + field: Type.String +}) {}`; + + expect(result).toBe(expected); + }); + + it('should filter out entities with no properties', ({ expect }) => { + const schema = { + types: [ + { + name: 'EmptyEntity', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [], + }, + { + name: 'ValidEntity', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'field', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class ValidEntity extends Entity.Class('ValidEntity')({ + field: Type.String +}) {}`; + + expect(result).toBe(expected); + }); + + it('should filter out properties with empty names', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: '', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'validField', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class User extends Entity.Class('User')({ + validField: Type.String +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle complex schema with multiple entities and various property types', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'email', + dataType: 'String' as const, + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: true, + status: 'published' as const, + }, + ], + }, + { + name: 'Event', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: 'c126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'start_date', + dataType: 'Date' as const, + knowledgeGraphId: 'd126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'location', + dataType: 'Point' as const, + knowledgeGraphId: 'e126ca53-0c8e-48d5-b888-82c734c38935', + optional: true, + status: 'published' as const, + }, + { + name: 'organizer', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: 'f126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class User extends Entity.Class('User')({ + name: Type.String, + email: Type.optional(Type.String) +}) {} + +export class Event extends Entity.Class('Event')({ + title: Type.String, + startDate: Type.Date, + location: Type.optional(Type.Point), + organizer: Type.Relation(User) +}) {}`; + + expect(result).toBe(expected); + }); + + it('should handle unknown data types by defaulting to String', ({ expect }) => { + const schema = { + types: [ + { + name: 'TestEntity', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'unknown_field', + // biome-ignore lint/suspicious/noExplicitAny: test cases + dataType: 'UnknownType' as any, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class TestEntity extends Entity.Class('TestEntity')({ + unknownField: Type.String +}) {}`; + + expect(result).toBe(expected); + }); + + it('should convert class names to PascalCase', ({ expect }) => { + const schema = { + types: [ + { + name: 'user_account', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'username', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + { + name: 'blog-post', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildSchemaFile(schema); + const expected = `import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class UserAccount extends Entity.Class('UserAccount')({ + username: Type.String +}) {} + +export class BlogPost extends Entity.Class('BlogPost')({ + title: Type.String +}) {}`; + + expect(result).toBe(expected); + }); +}); + +describe('buildMappingFileFromSchema', () => { + it('should build mapping file with single entity', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + User: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle multiple entities', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'Post', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + User: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, + Post: { + typeIds: [Id("8f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + title: Id("b126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle entities with multiple properties', ({ expect }) => { + const schema = { + types: [ + { + name: 'Product', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'price', + dataType: 'Number' as const, + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'available', + dataType: 'Boolean' as const, + knowledgeGraphId: 'c126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + Product: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935"), + price: Id("b126ca53-0c8e-48d5-b888-82c734c38935"), + available: Id("c126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle entities with relations', ({ expect }) => { + const schema = { + types: [ + { + name: 'Post', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'author', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'User', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'c126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + Post: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + title: Id("a126ca53-0c8e-48d5-b888-82c734c38935") + }, + relations: { + author: Id("b126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, + User: { + typeIds: [Id("8f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("c126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle entities with both properties and relations', ({ expect }) => { + const schema = { + types: [ + { + name: 'Event', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'description', + dataType: 'String' as const, + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'sponsors', + dataType: 'Relation(Company)' as const, + relationType: 'Company', + knowledgeGraphId: '6860bfac-f703-4289-b789-972d0aaf3abe', + optional: undefined, + status: 'published' as const, + }, + { + name: 'attendees', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: 'd126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'Company', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'e126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'User', + knowledgeGraphId: '9f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'f126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + Event: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935"), + description: Id("b126ca53-0c8e-48d5-b888-82c734c38935") + }, + relations: { + sponsors: Id("6860bfac-f703-4289-b789-972d0aaf3abe"), + attendees: Id("d126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, + Company: { + typeIds: [Id("8f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("e126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, + User: { + typeIds: [Id("9f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("f126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle entities with no knowledgeGraphId', ({ expect }) => { + const schema = { + types: [ + { + name: 'User', + knowledgeGraphId: null, + status: 'synced' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + + // When knowledgeGraphId is null, generateMapping will create new IDs + // We just check the structure is correct + expect(result).toMatch(/import \{ Id \} from '@graphprotocol\/hypergraph';/); + expect(result).toMatch(/import type \{ Mapping \} from '@graphprotocol\/hypergraph\/mapping';/); + expect(result).toMatch(/export const mapping: Mapping = \{/); + expect(result).toMatch(/User: \{/); + expect(result).toMatch(/typeIds: \[Id\("[a-f0-9-]+"\)\],/); + expect(result).toMatch(/properties: \{/); + expect(result).toMatch(/name: Id\("[a-f0-9-]+"\)/); + }); + + it('should handle entities with only properties and no relations', ({ expect }) => { + const schema = { + types: [ + { + name: 'Settings', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'theme', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'notifications_enabled', + dataType: 'Boolean' as const, + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + Settings: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + theme: Id("a126ca53-0c8e-48d5-b888-82c734c38935"), + notificationsEnabled: Id("b126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle entities with only relations and no properties', ({ expect }) => { + const schema = { + types: [ + { + name: 'Friendship', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'user1', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'user2', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'User', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'c126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + Friendship: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + relations: { + user1: Id("a126ca53-0c8e-48d5-b888-82c734c38935"), + user2: Id("b126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, + User: { + typeIds: [Id("8f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("c126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should convert snake_case property names to camelCase', ({ expect }) => { + const schema = { + types: [ + { + name: 'UserProfile', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'first_name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'last_name', + dataType: 'String' as const, + knowledgeGraphId: 'b126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'profile_picture', + dataType: 'Relation(Image)' as const, + relationType: 'Image', + knowledgeGraphId: 'c126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'Image', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'url', + dataType: 'String' as const, + knowledgeGraphId: 'd126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + UserProfile: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + firstName: Id("a126ca53-0c8e-48d5-b888-82c734c38935"), + lastName: Id("b126ca53-0c8e-48d5-b888-82c734c38935") + }, + relations: { + profilePicture: Id("c126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, + Image: { + typeIds: [Id("8f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + url: Id("d126ca53-0c8e-48d5-b888-82c734c38935") + }, + }, +}`; + + expect(result).toBe(expected); + }); + + it('should handle mixed properties with and without knowledgeGraphId', ({ expect }) => { + const schema = { + types: [ + { + name: 'Article', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'title', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'content', + dataType: 'String' as const, + knowledgeGraphId: null, + optional: undefined, + status: 'synced' as const, + }, + { + name: 'author', + dataType: 'Relation(User)' as const, + relationType: 'User', + knowledgeGraphId: 'c126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'User', + knowledgeGraphId: '8f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'd126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + + // Check structure and that title and author have the expected IDs + expect(result).toMatch(/title: Id\("a126ca53-0c8e-48d5-b888-82c734c38935"\)/); + expect(result).toMatch(/author: Id\("c126ca53-0c8e-48d5-b888-82c734c38935"\)/); + // content should have a generated ID + expect(result).toMatch(/content: Id\("[a-f0-9-]+"\)/); + }); + + it('should handle complex schema similar to the example in documentation', ({ expect }) => { + const schema = { + types: [ + { + name: 'Event', + knowledgeGraphId: '7f9562d4-034d-4385-bf5c-f02cdebba47a', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'sponsors', + dataType: 'Relation(Company)' as const, + relationType: 'Company', + knowledgeGraphId: '6860bfac-f703-4289-b789-972d0aaf3abe', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'Company', + knowledgeGraphId: '6c504df5-1a8f-43d1-bf2d-1ef9fa5b08b5', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'job_offers', + dataType: 'Relation(JobOffer)' as const, + relationType: 'JobOffer', + knowledgeGraphId: '1203064e-9741-4235-89d4-97f4b22eddfb', + optional: undefined, + status: 'published' as const, + }, + ], + }, + { + name: 'JobOffer', + knowledgeGraphId: 'f60585af-71b6-4674-9a26-b74ca6c1cceb', + status: 'published' as const, + properties: [ + { + name: 'name', + dataType: 'String' as const, + knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', + optional: undefined, + status: 'published' as const, + }, + { + name: 'salary', + dataType: 'Number' as const, + knowledgeGraphId: 'baa36ac9-78ac-4cf7-8394-6b2d3006bebe', + optional: undefined, + status: 'published' as const, + }, + ], + }, + ], + }; + + const result = buildMappingFileFromSchema(schema); + const expected = `import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import { Id } from '@graphprotocol/hypergraph'; + +export const mapping: Mapping = { + Event: { + typeIds: [Id("7f9562d4-034d-4385-bf5c-f02cdebba47a")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935") + }, + relations: { + sponsors: Id("6860bfac-f703-4289-b789-972d0aaf3abe") + }, + }, + Company: { + typeIds: [Id("6c504df5-1a8f-43d1-bf2d-1ef9fa5b08b5")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935") + }, + relations: { + jobOffers: Id("1203064e-9741-4235-89d4-97f4b22eddfb") + }, + }, + JobOffer: { + typeIds: [Id("f60585af-71b6-4674-9a26-b74ca6c1cceb")], + properties: { + name: Id("a126ca53-0c8e-48d5-b888-82c734c38935"), + salary: Id("baa36ac9-78ac-4cf7-8394-6b2d3006bebe") + }, + }, +}`; + + expect(result).toBe(expected); + }); +}); diff --git a/packages/hypergraph/tsconfig.json b/packages/hypergraph/tsconfig.json index 8f72f9f5..52c2cfc2 100644 --- a/packages/hypergraph/tsconfig.json +++ b/packages/hypergraph/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "include": [], "references": [ - { "path": "./tsconfig.src.json" } + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.scripts.json" } ] } diff --git a/packages/hypergraph/tsconfig.scripts.json b/packages/hypergraph/tsconfig.scripts.json new file mode 100644 index 00000000..dd165cc4 --- /dev/null +++ b/packages/hypergraph/tsconfig.scripts.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "scripts", + ], + "compilerOptions": { + "types": [ + "node" + ], + "tsBuildInfoFile": ".tsbuildinfo/scripts.tsbuildinfo", + "rootDir": ".", + "noEmit": true + } +} diff --git a/packages/hypergraph/typesync-studio/.cta.json b/packages/hypergraph/typesync-studio/.cta.json new file mode 100644 index 00000000..54a86005 --- /dev/null +++ b/packages/hypergraph/typesync-studio/.cta.json @@ -0,0 +1,11 @@ +{ + "projectName": "typesync-studio", + "mode": "file-router", + "typescript": true, + "tailwind": true, + "packageManager": "pnpm", + "git": false, + "version": 1, + "framework": "react-cra", + "chosenAddOns": ["tanstack-query", "form"] +} diff --git a/packages/hypergraph/typesync-studio/LICENSE b/packages/hypergraph/typesync-studio/LICENSE new file mode 100644 index 00000000..3ddb85bf --- /dev/null +++ b/packages/hypergraph/typesync-studio/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present Geo Browser, PB LLC and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/hypergraph/typesync-studio/README.md b/packages/hypergraph/typesync-studio/README.md new file mode 100644 index 00000000..3694b73e --- /dev/null +++ b/packages/hypergraph/typesync-studio/README.md @@ -0,0 +1,3 @@ +# @graphprotocol/hypergraph TypeSync Studio + +App that reads the Hypergraph schema and mappings from the directory where @graphprotocol/hypergraph is installed and lets you browse existing schemas from The Knowledge Graph to update your Hypergraph schema. \ No newline at end of file diff --git a/packages/hypergraph/typesync-studio/graphql.codegen.ts b/packages/hypergraph/typesync-studio/graphql.codegen.ts new file mode 100644 index 00000000..2bbc2b9b --- /dev/null +++ b/packages/hypergraph/typesync-studio/graphql.codegen.ts @@ -0,0 +1,37 @@ +import { Graph } from '@graphprotocol/grc-20'; +import type { CodegenConfig } from '@graphql-codegen/cli'; +import type { TypeScriptPluginConfig } from '@graphql-codegen/typescript'; +import type { TypeScriptDocumentsPluginConfig } from '@graphql-codegen/typescript-operations'; + +interface PluginConfig extends TypeScriptPluginConfig, TypeScriptDocumentsPluginConfig {} + +const pluginConfig = { + arrayInputCoercion: false, + enumsAsTypes: true, + dedupeFragments: true, + scalars: { + UUID: 'string', + }, +} satisfies PluginConfig; + +const config = { + overwrite: true, + generates: { + './src/generated/': { + schema: `${Graph.TESTNET_API_ORIGIN}/graphql`, + documents: ['./src/**/*.{ts,tsx}'], + preset: 'client', + config: pluginConfig, + presetConfig: { + /** + * We're not using fragments to colocate data requirements with components, + * so fragment masking ends up causing a lot of unnecessary `unmask/useFragment` calls. + * @see https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#embrace-fragment-masking-principles + */ + fragmentMasking: false, + }, + }, + }, +} as const satisfies CodegenConfig; + +export default config; diff --git a/packages/hypergraph/typesync-studio/index.html b/packages/hypergraph/typesync-studio/index.html new file mode 100644 index 00000000..f149b3c2 --- /dev/null +++ b/packages/hypergraph/typesync-studio/index.html @@ -0,0 +1,29 @@ + + + + + + + + Graph Protocol | Hypergraph TypeSync + + + + +
+ + + diff --git a/packages/hypergraph/typesync-studio/package.json b/packages/hypergraph/typesync-studio/package.json new file mode 100644 index 00000000..85268fb6 --- /dev/null +++ b/packages/hypergraph/typesync-studio/package.json @@ -0,0 +1,55 @@ +{ + "name": "typesync-studio", + "description": "App that reads the Hypergraph schema and mappings from the directory where @graphprotocol/hypergraph is installed and lets you browse existing schemas from The Knowledge Graph to update your Hypergraph schema.", + "private": true, + "type": "module", + "scripts": { + "codegen:gql": "graphql-codegen --config ./graphql.codegen.ts", + "dev": "vite", + "start": "vite", + "build": "vite build && tsc", + "serve": "vite preview", + "test": "vitest run", + "lint:fix": "biome check --write --unsafe" + }, + "dependencies": { + "@base-ui-components/react": "1.0.0-beta.2", + "@graphprotocol/grc-20": "^0.24.1", + "@graphprotocol/hypergraph": "workspace:*", + "@graphprotocol/hypergraph-react": "workspace:*", + "@graphql-typed-document-node/core": "^3.2.0", + "@headlessui/react": "^2.2.7", + "@phosphor-icons/react": "^2.1.10", + "@tanstack/react-form": "^1.19.1", + "@tanstack/react-query": "^5.85.0", + "@tanstack/react-query-devtools": "^5.85.0", + "@tanstack/react-router": "^1.131.7", + "@tanstack/react-router-devtools": "^1.131.7", + "@tanstack/router-plugin": "^1.131.7", + "effect": "3.17.6", + "graphql": "^16.11.0", + "graphql-request": "^7.2.0", + "lodash.debounce": "^4.0.8", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "tailwindcss": "^4.1.11", + "zod": "^4.0.17" + }, + "devDependencies": { + "@graphql-codegen/cli": "^5.0.7", + "@graphql-codegen/client-preset": "^4.8.3", + "@graphql-codegen/typescript": "^4.1.6", + "@graphql-codegen/typescript-operations": "^4.6.1", + "@tailwindcss/vite": "^4.1.11", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/lodash.debounce": "^4.0.9", + "@types/node": "^24.2.0", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.7.0", + "jsdom": "^26.1.0", + "vite": "^7.0.6", + "web-vitals": "^5.1.0" + } +} diff --git a/packages/hypergraph/typesync-studio/public/manifest.json b/packages/hypergraph/typesync-studio/public/manifest.json new file mode 100644 index 00000000..1a630cec --- /dev/null +++ b/packages/hypergraph/typesync-studio/public/manifest.json @@ -0,0 +1,20 @@ +{ + "short_name": "Hypergraph Typesync Studio", + "name": "Graph Protocol Hypergraph Typesync Studio", + "icons": [ + { + "src": "https://storage.thegraph.com/favicons/64x64.png", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/png" + }, + { + "src": "https://storage.thegraph.com/favicons/256x256.png", + "type": "image/png", + "sizes": "256x256" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/hypergraph/typesync-studio/public/robots.txt b/packages/hypergraph/typesync-studio/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/packages/hypergraph/typesync-studio/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/packages/hypergraph/typesync-studio/src/Components/Arrow.tsx b/packages/hypergraph/typesync-studio/src/Components/Arrow.tsx new file mode 100644 index 00000000..8a58690f --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Arrow.tsx @@ -0,0 +1,19 @@ +export function Arrow(props: React.ComponentProps<'svg'>) { + return ( + + Tooltip positioner arrow + + + + + ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Auth/UserPill.tsx b/packages/hypergraph/typesync-studio/src/Components/Auth/UserPill.tsx new file mode 100644 index 00000000..f4d4b249 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Auth/UserPill.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useHypergraphApp, useHypergraphAuth } from '@graphprotocol/hypergraph-react'; +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; +import { SignOutIcon } from '@phosphor-icons/react'; + +import { shorten } from '@/utils/string.ts'; + +export function UserPill() { + const { logout, redirectToConnect } = useHypergraphApp(); + const { authenticated, identity } = useHypergraphAuth(); + + if (authenticated && identity?.address) { + return ( + + + + Open user menu + {shorten(identity.address)} + + + logout()} + className="w-full flex rounded-md items-center gap-x-2 px-4 py-2 text-sm text-gray-700 dark:text-white data-focus:bg-gray-100 dark:data-focus:bg-slate-700 data-focus:outline-hidden" + > + + + + ); + } + + return ( + + ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Form/Checkbox.tsx b/packages/hypergraph/typesync-studio/src/Components/Form/Checkbox.tsx new file mode 100644 index 00000000..961123a4 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Form/Checkbox.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Checkbox as HeadlessCheckbox, type CheckboxProps as HeadlessCheckboxProps } from '@headlessui/react'; +import { CheckIcon } from '@phosphor-icons/react'; + +import { useFieldContext } from './form.ts'; + +export type CheckboxProps = Omit & { + id: string; + name: string; + label: React.ReactNode; +}; +export function Checkbox({ id, name, label, ...rest }: Readonly) { + const field = useFieldContext(); + + return ( +
+ {name} +
+
+
+ + +
+
+
+ +
+
+
+ ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Form/ErrorMessages.tsx b/packages/hypergraph/typesync-studio/src/Components/Form/ErrorMessages.tsx new file mode 100644 index 00000000..d94ba83d --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Form/ErrorMessages.tsx @@ -0,0 +1,17 @@ +export function ErrorMessages({ + id, + errors, +}: Readonly<{ id: string | undefined; errors: Array }>) { + return ( +
+ {errors.map((error, idx) => { + const key = `${id}__errorMessage__${idx}`; + return ( +
+ {typeof error === 'string' ? error : error.message} +
+ ); + })} +
+ ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Form/SubmitButton.tsx b/packages/hypergraph/typesync-studio/src/Components/Form/SubmitButton.tsx new file mode 100644 index 00000000..2215bdd3 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Form/SubmitButton.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { Tooltip } from '@base-ui-components/react/tooltip'; +import { CheckIcon, ExclamationMarkIcon } from '@phosphor-icons/react'; + +import { classnames } from '@/utils/classnames.ts'; +import { Arrow } from '../Arrow.tsx'; +import { Loading } from '../Loading.tsx'; +import { useFormContext } from './form.ts'; + +export function SubmitButton({ + status, + tooltip, + children, +}: Readonly<{ + status: 'idle' | 'error' | 'success' | 'submitting'; + /** + * If provided, then render a rooltip around the button with additional details + */ + tooltip?: + | { + disabled?: boolean | undefined; + content: React.ReactNode; + } + | null + | undefined; + children: React.ReactNode; +}>) { + const form = useFormContext(); + + return ( + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + valid: state.isValid && state.errors.length === 0, + dirty: state.isDirty, + })} + > + {({ canSubmit, isSubmitting, valid, dirty }) => { + if (tooltip) { + return ( + + + + {status === 'submitting' ? ( + + ) : status === 'success' ? ( + <> + + + + + + + + + {tooltip.content} + + + + + + + ); + } + + return ( + + ); + }} + + ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Form/TextField.tsx b/packages/hypergraph/typesync-studio/src/Components/Form/TextField.tsx new file mode 100644 index 00000000..303ad678 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Form/TextField.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Input, type InputProps } from '@headlessui/react'; +import { useStore } from '@tanstack/react-form'; + +import { classnames } from '@/utils/classnames.ts'; +import { ErrorMessages } from './ErrorMessages.tsx'; +import { useFieldContext } from './form.ts'; + +export type TextFieldProps = Omit & { + id: string; + label?: React.ReactNode; + hint?: React.ReactNode; +}; +export function TextField({ id, label, hint, ...rest }: Readonly) { + const field = useFieldContext(); + const errors = useStore(field.store, (state) => state.meta.errors); + const touched = useStore(field.store, (state) => state.meta.isTouched); + const hasErrors = errors.length > 0 && touched; + + return ( +
+ {label != null ? ( + + ) : null} +
+
+ { + field.handleChange(e.target.value); + rest.onChange?.(e); + }} + data-state={hasErrors ? 'invalid' : undefined} + aria-invalid={hasErrors ? 'true' : undefined} + aria-describedby={hasErrors ? `${id}-invalid` : hint != null ? `${id}-hint` : undefined} + className="block min-w-0 grow py-1.5 pl-1 pr-3 data-[state=invalid]:pr-10 text-base text-gray-900 dark:text-white data-[state=invalid]:text-red-900 dark:data-[state=invalid]:text-red-700 placeholder:text-gray-400 dark:placeholder:text-gray-500 data-[state=invalid]:placeholder:text-red-700 dark:data-[state=invalid]:placeholder:text-red-400 focus:outline sm:text-sm/6 focus-visible:outline-none" + /> +
+ {hasErrors ? : null} + {hint != null && !hasErrors ? ( +

+ {hint} +

+ ) : null} +
+
+ ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Form/form.ts b/packages/hypergraph/typesync-studio/src/Components/Form/form.ts new file mode 100644 index 00000000..0630e0dc --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Form/form.ts @@ -0,0 +1,3 @@ +import { createFormHookContexts } from '@tanstack/react-form'; + +export const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts(); diff --git a/packages/hypergraph/typesync-studio/src/Components/InlineCode.tsx b/packages/hypergraph/typesync-studio/src/Components/InlineCode.tsx new file mode 100644 index 00000000..d77cb49c --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/InlineCode.tsx @@ -0,0 +1,10 @@ +export function InlineCode({ children, ...rest }: React.ComponentProps<'span'>) { + return ( + + {children} + + ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Loading.tsx b/packages/hypergraph/typesync-studio/src/Components/Loading.tsx new file mode 100644 index 00000000..10781242 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Loading.tsx @@ -0,0 +1,7 @@ +export function Loading() { + return ( +
+
+
+ ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Schema/AppSchemaSpaceDialog.tsx b/packages/hypergraph/typesync-studio/src/Components/Schema/AppSchemaSpaceDialog.tsx new file mode 100644 index 00000000..588543b7 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Schema/AppSchemaSpaceDialog.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { Id, type SpaceStorageEntry } from '@graphprotocol/hypergraph'; +import { useHypergraphApp, useHypergraphAuth, useSpaces } from '@graphprotocol/hypergraph-react'; +import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'; +import { WarningIcon, XIcon } from '@phosphor-icons/react'; +import { createFormHook, useStore } from '@tanstack/react-form'; +import { Schema } from 'effect'; + +import { fieldContext, formContext, useFieldContext } from '@/Components/Form/form.ts'; +import { SubmitButton } from '@/Components/Form/SubmitButton.tsx'; +import { useAppSchemaSpace } from '@/Context/AppSchemaSpaceContext'; +import { ErrorMessages } from '../Form/ErrorMessages'; +import { InlineCode } from '../InlineCode'; + +const SelectAppSchemaSpaceFormSchema = Schema.Struct({ + spaceId: Schema.String, + name: Schema.String, +}); +type SelectAppSchemaSpaceFormSchema = typeof SelectAppSchemaSpaceFormSchema.Type; + +const { useAppForm } = createFormHook({ + fieldComponents: { + SchemaSpaceSelect, + }, + formComponents: { + SubmitButton, + }, + fieldContext, + formContext, +}); + +export function AppSchemaSpaceDialog({ + open, + setOpen, + spaceSubmitted, +}: Readonly<{ + open: boolean; + setOpen(open: boolean): void; + /** + * Use this option if the user has selected a space to publish to and submitted the form + * @param args the selected space data + */ + spaceSubmitted(args: Readonly<{ id: Id; name: string | null }>): void; +}>) { + const { setAppSchemaSpace } = useAppSchemaSpace(); + + const defaultValues: SelectAppSchemaSpaceFormSchema = { + spaceId: '', + name: '', + }; + const selectAppSchemaSpaceForm = useAppForm({ + defaultValues, + validators: { + onChange: Schema.standardSchemaV1(SelectAppSchemaSpaceFormSchema), + }, + onSubmit({ value }) { + const space = { + id: Id(value.spaceId), + name: value.name, + }; + setAppSchemaSpace(space); + spaceSubmitted(space); + }, + }); + const selectedSpace = useStore(selectAppSchemaSpaceForm.store, (state) => state.values.name); + + return ( + + + +
{ + e.preventDefault(); + e.stopPropagation(); + void selectAppSchemaSpaceForm.handleSubmit(); + }} + > +
+ +
+ +
+
+
+
+
+ + Select Space + +
+ + {(field) => ( + { + selectAppSchemaSpaceForm.setFieldValue('name', space.name || space.id); + selectAppSchemaSpaceForm.setFieldValue('spaceId', space.id); + }} + /> + )} + +
+
+
+
+ + + Select Space {selectedSpace} & Publish + + + +
+
+
+
+
+ ); +} + +type SchemaSpaceSelectProps = { + id: string; + name: string; + spaceSelected( + space: + | SpaceStorageEntry + | { + id: string; + name: string | undefined; + spaceAddress: string; + }, + ): void; +}; +function SchemaSpaceSelect({ id, name, spaceSelected }: Readonly) { + const { redirectToConnect } = useHypergraphApp(); + const { authenticated } = useHypergraphAuth(); + const { data: publicSpaces = [], isPending } = useSpaces({ mode: 'public' }); + + const field = useFieldContext(); + const value = useStore(field.store, (state) => state.value); + const errors = useStore(field.store, (state) => state.meta.errors); + const touched = useStore(field.store, (state) => state.meta.isTouched); + const hasErrors = errors.length > 0 && touched; + + if (!authenticated) { + return ( + + ); + } + if (!isPending && publicSpaces.length === 0) { + return ( +
+

+ Schemas need to be published to a public Space you have granted access to this app +

+

Reconnect to Geo connect and connect public spaces

+ +
+ ); + } + + return ( +
+
+ + Select the Space to publish your Hypergraph shema to. + + {publicSpaces.map((publicSpace) => ( + + ))} +
+ {hasErrors ? : null} +
+ ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Schema/Icons/PublishedToKnowledgeGraph.tsx b/packages/hypergraph/typesync-studio/src/Components/Schema/Icons/PublishedToKnowledgeGraph.tsx new file mode 100644 index 00000000..fdbaa04e --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Schema/Icons/PublishedToKnowledgeGraph.tsx @@ -0,0 +1,74 @@ +export function PublishedToKnowledgeGraphDark(props: React.ComponentProps<'svg'>) { + return ( + + + Published to Knowledge Graph + + + + + + + + + + + + + + + + + + + ); +} +export function PublishedToKnowledgeGraphLight(props: React.ComponentProps<'svg'>) { + return ( + + + Published to Knowledge Graph + + + + + + + + + + + + + + + + + + + ); +} +export function PublishedToKnowledgeGraphIcon(props: React.ComponentProps<'svg'>) { + return ( + <> + + + + ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Schema/KnowledgeGraphBrowser.tsx b/packages/hypergraph/typesync-studio/src/Components/Schema/KnowledgeGraphBrowser.tsx new file mode 100644 index 00000000..ff23ddee --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Schema/KnowledgeGraphBrowser.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'; +import { CaretDownIcon, CaretRightIcon, PlusIcon } from '@phosphor-icons/react'; +import { createFormHook } from '@tanstack/react-form'; +import { Array as EffectArray, String as EffectString } from 'effect'; +import { useState } from 'react'; + +import { fieldContext, formContext } from '@/Components/Form/form.ts'; +import { TextField } from '@/Components/Form/TextField.tsx'; +import { type ExtendedSchemaBrowserType, useSchemaBrowserQuery } from '@/hooks/useKnowledgeGraph.tsx'; +import { mapKGDataTypeToPrimitiveType } from '@/utils/type-mapper.ts'; +import { InlineCode } from '../InlineCode.tsx'; +import { Loading } from '../Loading.tsx'; + +const { useAppForm } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: {}, + fieldContext, + formContext, +}); + +export type KnowledgeGraphBrowserProps = Readonly<{ + typeSelected(type: ExtendedSchemaBrowserType): void; +}>; +export function KnowledgeGraphBrowser({ typeSelected }: KnowledgeGraphBrowserProps) { + const [typeSearch, setTypeSearch] = useState(''); + const schemaBrowserForm = useAppForm({ + defaultValues: { + search: '', + } as { + search: string; + }, + asyncDebounceMs: 300, + }); + + const { data: types, isLoading } = useSchemaBrowserQuery( + { + query: EffectString.isNonEmpty(typeSearch) ? EffectString.toLowerCase(typeSearch) : null, + first: 100, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return ( +
+
+

+ Schema Browser + {isLoading ? : null} +

+

+ Browse existing schemas/types from the Knowledge Graph to add to your schema. +

+
+
+
+ + {(field) => ( + + )} + +
+
    + {(types ?? []).map((_type) => { + const properties = EffectArray.filter(_type.properties ?? [], (prop) => prop != null); + + return ( + +
    + +
    + {/* shown when the collapsible is open */} +
    +
    +
    + +
    +
    + +
      + {properties.map((prop) => ( +
    • + {prop.name || prop.id} + {prop.dataType != null ? ( + {mapKGDataTypeToPrimitiveType(prop.dataType, prop.name || prop.id)} + ) : null} +
    • + ))} +
    +
    +
    + ); + })} +
+
+
+ ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Schema/PropertyCombobox.tsx b/packages/hypergraph/typesync-studio/src/Components/Schema/PropertyCombobox.tsx new file mode 100644 index 00000000..a171813c --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Schema/PropertyCombobox.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { + Combobox, + ComboboxButton, + ComboboxInput, + type ComboboxInputProps, + ComboboxOption, + ComboboxOptions, + Label, +} from '@headlessui/react'; +import { CaretUpDownIcon, CheckIcon } from '@phosphor-icons/react'; +import { useStore } from '@tanstack/react-form'; +import { String as EffectString } from 'effect'; +import debounce from 'lodash.debounce'; +import { useState } from 'react'; + +import { type ExtendedProperty, usePropertiesQuery } from '@/hooks/useKnowledgeGraph.tsx'; +import { classnames } from '@/utils/classnames.ts'; +import { shorten } from '@/utils/string.ts'; +import { mapKGDataTypeToPrimitiveType } from '@/utils/type-mapper.ts'; +import { ErrorMessages } from '../Form/ErrorMessages.tsx'; +import { useFieldContext } from '../Form/form.ts'; +import { InlineCode } from '../InlineCode.tsx'; + +export type PropertyCombobox = Omit & { + id: string; + label?: React.ReactNode; + propertySelected(prop: ExtendedProperty): void; +}; +export function PropertyCombobox({ id, label, propertySelected, ...rest }: Readonly) { + const field = useFieldContext(); + const value = useStore(field.store, (state) => state.value); + const errors = useStore(field.store, (state) => state.meta.errors); + const touched = useStore(field.store, (state) => state.meta.isTouched); + const hasErrors = errors.length > 0 && touched; + + const [propsFilter, setPropsFilter] = useState(''); + const debounceSearch = debounce<(val: string) => void>((val: string) => { + setPropsFilter(val); + }, 300); + + const { data } = usePropertiesQuery( + { + query: EffectString.isNonEmpty(propsFilter) ? EffectString.toLowerCase(propsFilter) : null, + first: 50, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + const props = data ?? []; + + return ( + + value={value} + onChange={(val) => { + if (val == null) { + field.handleChange(''); + return; + } + if (typeof val === 'string') { + field.handleChange(val); + return; + } + field.handleChange(val.name || val.id); + propertySelected(val); + }} + onClose={() => debounceSearch('')} + immediate + > + {label != null ? ( + + ) : null} +
+ { + const value = event.target.value; + debounceSearch(value); + field.handleChange(value); + }} + onBlur={() => setPropsFilter('')} + displayValue={(selectedType: string) => selectedType} + /> + + + + {props.length > 0 || EffectString.isNonEmpty(propsFilter) ? ( + + {props.map((_prop) => ( + +
+
+

+ {_prop.name || _prop.id} + {mapKGDataTypeToPrimitiveType(_prop.dataType, _prop.name || _prop.id)} +

+

+ {_prop.id} +

+

+ {shorten(_prop.id)} +

+
+

+ {_prop.description} +

+
+ + + +
+ ))} + {EffectString.isNonEmpty(propsFilter) ? ( + + + New Property: {propsFilter} + + + ) : null} +
+ ) : null} +
+ {hasErrors ? : null} + + ); +} diff --git a/packages/hypergraph/typesync-studio/src/Components/Schema/Status.tsx b/packages/hypergraph/typesync-studio/src/Components/Schema/Status.tsx new file mode 100644 index 00000000..da5e7002 --- /dev/null +++ b/packages/hypergraph/typesync-studio/src/Components/Schema/Status.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { Tooltip } from '@base-ui-components/react/tooltip'; +import type { Typesync } from '@graphprotocol/hypergraph'; + +import { Arrow } from '../Arrow.tsx'; +import { PublishedToKnowledgeGraphIcon } from './Icons/PublishedToKnowledgeGraph.tsx'; + +export function SchemaPropertyStatus({ status }: Readonly<{ status: Typesync.TypesyncHypergraphSchemaStatus }>) { + const content = StatusTooltipContentMap[status || 'typesync_ui']; + + return ( + + + + + + + + + + + + {content} + + + + + + ); +} + +function CorrectIcon({ status }: Readonly<{ status: Typesync.TypesyncHypergraphSchemaStatus }>) { + if (status === 'published') { + // type/property is published to the Knowledge Graph + return