diff --git a/apps/events/src/components/events.tsx b/apps/events/src/components/events.tsx new file mode 100644 index 00000000..f0ba69c7 --- /dev/null +++ b/apps/events/src/components/events.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { useCreateEntity, useQuery, useSpaceId } from "./schema"; +import { Button } from "./ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Input } from "./ui/input"; + +export const Events: React.FC = () => { + const [newTodo, setNewTodo] = React.useState(""); + const id = useSpaceId(); + const createEntity = useCreateEntity(); + + const entities = useQuery({ types: ["Event"] }); + + // const entities = useQuery({ + // where: { + // name: { + // equals: "Alice", + // }, + // // TODO include it a where clause + // types: { + // contains: ["Person"] as const, + // }, + // select: { + // name: true, + // friends: { + // // where: {}, + // select: { + // name: true, + // }, + // }, + // }, + // }, + // }); + + // useEffect(() => { + // // createEntity({ + // // types: ["Event"], + // // data: { + // // name: "Silvester in NY", + // // }, + // // }); + + // // TODO create - can be an object or an array + // createEntity({ + // types: ["Person", "User"], // TODO can types be inferred if they are located in data? + // data: { + // name: "Alice", + // age: 30, + // email: "alice@example.com", + // // isActive: true, + // // friends: ["abc", "def"], // ids to connect + // // friends: [ + // // { id: "abc", name: "abc" }, + // // { id: "def", name: "lala" }, + // // ], // create objects or overwrite existing ones + // // friends: ["abc", { id: "def", name: "lala" }], // mix between connect and create + // }, + // }); + + // // createEntity(["Person", "User"], { + // // name: "Alice", + // // age: 30, + // // email: "alice@example.com", + // // isActive: true, + // // }); + // }, []); + + // TODO different API for setting a Triple with a value on a entities + + return ( +
+
+
+

Events of Space w/ ID: {id}

+ +
{ + event.preventDefault(); + createEntity({ + types: ["Event"], + data: { + name: "Bob", + }, + }); + setNewTodo(""); + }} + > + setNewTodo(event.target.value)} + value={newTodo} + /> + +
+
+ {entities && + Object.keys(entities).map((entityId) => { + const entity = entities[entityId]; + return ( + + + + { + // changeDoc((doc) => { + // doc.events[event.id].value = evt.target.value; + // }); + }} + value={entity.name} + /> + + + +

A new event

+
+ + + +
+ ); + })} +
+
+
+
+ ); +}; diff --git a/apps/events/src/components/schema-test-automerge.tsx b/apps/events/src/components/schema-test-automerge.tsx index 9c093b03..6e5e539d 100644 --- a/apps/events/src/components/schema-test-automerge.tsx +++ b/apps/events/src/components/schema-test-automerge.tsx @@ -1,36 +1,6 @@ -import { - createDocumentId, - SpaceProvider, - type, - useCreateEntity, - useSpaceDocument, - useSpaceId, -} from "graph-framework"; import React, { useEffect } from "react"; -import { v4 as uuidv4 } from "uuid"; -import { Button } from "./ui/button"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "./ui/card"; -import { Input } from "./ui/input"; - -const schema = { - attributes: { - name: type.Text, - age: type.Number, - isActive: type.Checkbox, - email: type.Text, - }, - types: { - Person: ["name", "age"] as const, - User: ["name", "email", "isActive"] as const, - Event: ["name"] as const, - }, -}; +import { Events } from "./events"; +import { SpaceProvider, createDocumentId } from "./schema"; export const SchemaTestAutomerge: React.FC = () => { const [id, setId] = React.useState(null); @@ -46,114 +16,9 @@ export const SchemaTestAutomerge: React.FC = () => { return ( <> - + ); }; - -export const Events: React.FC = () => { - const [newTodo, setNewTodo] = React.useState(""); - const id = useSpaceId(); - const [doc, changeDoc] = useSpaceDocument(); - const createEntity = useCreateEntity< - typeof schema.attributes, - typeof schema.types - >(); - - useEffect(() => { - createEntity({ - types: ["Person", "User"], - data: { - name: "Alice", - age: 30, - email: "alice@example.com", - isActive: true, - }, - }); - createEntity({ - types: ["Event"], - data: { - name: "Bob", - }, - }); - }, []); - - return ( -
-
-
-

Events of Space w/ ID: {id}

- -
{ - event.preventDefault(); - - changeDoc((doc) => { - if (!doc.events) doc.events = {}; - const id = uuidv4(); - doc.events[id] = { - value: newTodo, - completed: false, - createdAt: new Date().getTime(), - }; - }); - setNewTodo(""); - }} - > - setNewTodo(event.target.value)} - value={newTodo} - /> - -
-
- {doc.events && - Object.keys(doc.events) - .map((id) => { - return { - ...doc.events[id], - id, - }; - }) - .sort((a, b) => b.createdAt - a.createdAt) - .map((event) => ( - - - - { - changeDoc((doc) => { - doc.events[event.id].value = evt.target.value; - }); - }} - value={event.value} - /> - - - -

A new event

-
- - - -
- ))} -
-
-
-
- ); -}; diff --git a/apps/events/src/components/schema.ts b/apps/events/src/components/schema.ts new file mode 100644 index 00000000..37f77903 --- /dev/null +++ b/apps/events/src/components/schema.ts @@ -0,0 +1,55 @@ +import { createFunctions, type } from "graph-framework"; + +// this is autogenerated +// const spaceTypes = { +// iban: S.Text, +// }; + +// const spaceAttributes = { +// iban: spaceTypes.Iban, +// age: type.Number, +// isActive: type.Checkbox, +// email: type.Text, +// }; + +// end autogenerated + +export const schema = { + attributes: { + // ...spaceAttributes, + name: type.Text, + age: type.Number, + isActive: type.Checkbox, + email: type.Text, + }, + relations: { + // friends: type.Relation(["User", "Person"], { + // cardinality: "many", // "one" | "many" + // }), + // soulmate: type.Relation(["User", "Person"], { + // cardinality: "one", // "one" | "many" + // }), + }, + types: { + Person: ["name", "age"] as const, + User: ["name", "email"] as const, + Event: ["name"] as const, + }, + // imports: { + // attributes: {}, + // types: { + // Animal: "abcd", + // }, + // relations: { + // owner: "xyz", + // }, + // }, +}; + +export const { + SpaceProvider, + useCreateEntity, + useSpaceId, + createDocumentId, + useQuery, +} = createFunctions(schema); diff --git a/docs/schema-graph-based.md b/docs/schema-graph-based.md index 6960bd26..d8bea9bc 100644 --- a/docs/schema-graph-based.md +++ b/docs/schema-graph-based.md @@ -266,3 +266,66 @@ const { schema: schemaV2, migrate: migrateV2 } : Migration({ - What about collections? - Can an entity have only attributes defined in a type or also additional ones? + +## New version + +Types: + +```tsx +import * as S from "@effect/schema/Schema"; + +export const type = { + Text: S.String, + Number: S.Number, + Checkbox: S.Boolean, +}; +``` + +Schema example: + +```tsx +export const schema: Schema = { + attributes: { + name: type.Text, + age: type.Number, + isActive: type.Checkbox, + email: type.Text, + }, + types: { + Person: ["name", "age"], + User: ["name", "email"], + Event: ["name"], + }, +}; +``` + +Hooks: + +```tsx +const createEntity = useCreateEntity(); +createEntity(["Person", "User"], { + // create entity with type Person and User + name: "John", + age: 30, + email: "john@example.com", +}); +``` + +```tsx +// query all entities of type Person and User where name is John +const entities = useQuery({ + where: { + type: ["Person", "User"], // must be both Person and User + name: { + equals: "John", + }, + }, +}); + +// query all entities of type Person +const entities = useQuery({ + where: { + type: ["Person"], + }, +}); +``` diff --git a/packages/graph-framework/context.tsx b/packages/graph-framework/context.tsx index a20b859c..e69ff8f6 100644 --- a/packages/graph-framework/context.tsx +++ b/packages/graph-framework/context.tsx @@ -1,4 +1,4 @@ -import { Repo } from "@automerge/automerge-repo"; +import { AnyDocumentId, Repo } from "@automerge/automerge-repo"; import { RepoContext, useDocument, @@ -6,12 +6,17 @@ import { import * as S from "@effect/schema/Schema"; import { createContext, ReactNode, useContext } from "react"; +interface SpaceProviderProps { + children: ReactNode; + id: string; +} + const repo = new Repo({ network: [], }); // Function to create schema functions -function createFunctions< +export function createFunctions< Attributes extends { [attrName: string]: S.Schema }, Types extends { [typeName: string]: ReadonlyArray }, >({ attributes, types }: { attributes: Attributes; types: Types }) { @@ -81,110 +86,110 @@ function createFunctions< return S.Struct(mergedFields); } - // createEntity function with type safety - function createEntity({ - types, - data, - }: { - types: [...TypeNames]; - data: MergedType; - }): MergedType { - if (types.length === 0) { - throw new Error("Entity must have at least one type"); - } + // Create a React Context to provide the schema + type SpaceContextProps = { + id: string; + }; - const mergedSchema = buildMergedSchema(types); - const result = S.decodeUnknownSync(mergedSchema)(data); + const SpaceContext = createContext(undefined); - return result as MergedType; + function SpaceProvider({ children, id }: SpaceProviderProps) { + const contextValue: SpaceContextProps = { + id, + }; + + return ( + + + {children} + + + ); } - return { - createEntity, + const useSpaceId = () => { + const context = useContext(SpaceContext); + if (!context) { + throw new Error("useSpaceId must be used within a SpaceProvider"); + } + return context?.id; }; -} -// Create a React Context to provide the schema -type SpaceContextProps< - Attributes extends { [attrName: string]: S.Schema }, - Types extends { [typeName: string]: readonly (keyof Attributes)[] }, -> = { - attributes: Attributes; - types: Types; - createEntity: ReturnType< - typeof createFunctions - >["createEntity"]; - id: string; -}; + const createDocumentId = () => { + const { documentId } = repo.create(); + return documentId; + }; -const SpaceContext = createContext | undefined>( - undefined -); + // Custom hook to use the createEntity function + function useCreateEntity() { + const id = useSpaceId(); + const [doc, changeDoc] = useDocument(id as AnyDocumentId); + + console.log("useCreateEntity doc", doc); + + function createEntity({ + types, + data, + }: { + types: [...TypeNames]; + data: MergedType; + }): MergedType { + if (types.length === 0) { + throw new Error("Entity must have at least one type"); + } -interface SpaceProviderProps< - Attributes extends { [attrName: string]: S.Schema }, - Types extends { [typeName: string]: ReadonlyArray }, -> { - schema: { attributes: Attributes; types: Types }; - children: ReactNode; - id: string; -} + const mergedSchema = buildMergedSchema(types); + const result = S.decodeUnknownSync(mergedSchema)(data); + + changeDoc((doc) => { + if (!doc.entities) { + doc.entities = {}; + } + const entityId = createDocumentId(); + doc.entities[entityId] = { + types, + data: result, + }; + }); + + return result as MergedType; + } -export function SpaceProvider< - Attributes extends { [attrName: string]: S.Schema }, - Types extends { [typeName: string]: ReadonlyArray }, ->({ schema, children, id }: SpaceProviderProps) { - const { createEntity } = createFunctions(schema); + return createEntity; + } - const contextValue: SpaceContextProps = { - ...schema, - createEntity, - id, - }; + function useQuery({ + types, + where, + }: { + types: [...TypeNames]; + where?: { + [AttrName in keyof Attributes]?: { + equals?: S.Schema.Type; + contains?: S.Schema.Type; + }; + }; + }) { + const id = useSpaceId(); + const [doc] = useDocument(id as AnyDocumentId); - return ( - - - {children} - - - ); -} + const entities = doc.entities || {}; -// Custom hook to use the schema context -export function useSchema< - Attributes extends { [attrName: string]: S.Schema }, - Types extends { [typeName: string]: ReadonlyArray }, ->() { - const context = useContext(SpaceContext); - if (!context) { - throw new Error("useSchema must be used within a SpaceProvider"); - } - return context as SpaceContextProps; -} + // iterate over entities and get the property data + const result: Record> = {}; + + for (const entityId in entities) { + result[entityId] = entities[entityId].data as MergedType; + } -export const useSpaceId = () => { - const context = useContext(SpaceContext); - if (!context) { - throw new Error("useSpaceId must be used within a SpaceProvider"); + return result; } - return context?.id; -}; -export const useSpaceDocument = () => { - const id = useSpaceId(); - // @ts-expect-error this is a valid URL - return useDocument(id); -}; -export const createDocumentId = () => { - const { documentId } = repo.create(); - return documentId; -}; - -// Custom hook to use the createEntity function -export function useCreateEntity< - Attributes extends { [attrName: string]: S.Schema }, - Types extends { [typeName: string]: ReadonlyArray }, ->() { - const { createEntity } = useSchema(); - return createEntity; + + return { + useCreateEntity, + useQuery, + SpaceProvider, + useSpaceId, + createDocumentId, + }; } diff --git a/packages/graph-framework/package.json b/packages/graph-framework/package.json index dedea7ba..47c1ee33 100644 --- a/packages/graph-framework/package.json +++ b/packages/graph-framework/package.json @@ -21,7 +21,11 @@ "@automerge/automerge-repo-react-hooks": "^1.2.1", "@effect/schema": "^0.74.1", "@types/react": "^18.3.3", + "@types/uuid": "^10.0.0", "vitest": "^2.1.1", "zod": "^3.23.8" + }, + "dependencies": { + "uuid": "^10.0.0" } } diff --git a/packages/graph-framework/schema/create-functions.ts b/packages/graph-framework/schema/create-functions.ts deleted file mode 100644 index ecff6013..00000000 --- a/packages/graph-framework/schema/create-functions.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as S from "@effect/schema/Schema"; - -export function createFunctions< - Attributes extends { [attrName: string]: S.Schema }, - Types extends { [typeName: string]: ReadonlyArray }, ->({ attributes, types }: { attributes: Attributes; types: Types }) { - // Build attribute schemas - const attributeSchemas: { - [K in keyof Attributes]: Attributes[K]; - } = attributes; - - // Build type schemas - const typeSchemas: { - [K in keyof Types]: S.Schema<{ - [AttrName in Types[K][number]]: S.Schema.Type< - (typeof attributeSchemas)[AttrName] - >; - }>; - } = {} as any; - - for (const typeName in types) { - const attrNames = types[typeName as keyof Types]; - const attrSchemaEntries: any = {}; - for (const attrName of attrNames) { - const attrSchema = attributeSchemas[attrName]; - if (!attrSchema) { - throw new Error(`Attribute ${String(attrName)} is not defined`); - } - attrSchemaEntries[attrName as string] = attrSchema; - } - typeSchemas[typeName as keyof Types] = S.Struct(attrSchemaEntries) as any; - } - - // Type for merged types - type TypeSchemasMap = typeof typeSchemas; - - type TypeSchemaTypes = S.Schema.Type< - TypeSchemasMap[T] - >; - - type UnionToIntersection = ( - U extends any ? (k: U) => void : never - ) extends (k: infer I) => void - ? I - : never; - - type MergedType = UnionToIntersection< - TypeSchemaTypes - >; - - // Helper function to build merged schema - function buildMergedSchema( - typesToCombine: [...TypeNames] - ): S.Schema { - const mergedFields: Record> = {}; - - for (const typeName of typesToCombine) { - const schema = typeSchemas[typeName]; - const structSchema = schema as S.Schema & { - fields: Record>; - }; - - if ("fields" in structSchema) { - Object.assign(mergedFields, structSchema.fields); - } else { - throw new Error(`Schema for type ${String(typeName)} is not a struct`); - } - } - - return S.Struct(mergedFields); - } - - // createEntity function with type safety - function createEntity({ - types, - data, - }: { - types: [...TypeNames]; - data: MergedType; - }): MergedType { - if (types.length === 0) { - throw new Error("Entity must have at least one type"); - } - - const mergedSchema = buildMergedSchema(types); - const result = S.decodeUnknownSync(mergedSchema)(data); - - return result as MergedType; - } - - return { - createEntity, - }; -} diff --git a/packages/graph-framework/schema/index.ts b/packages/graph-framework/schema/index.ts deleted file mode 100644 index 0f812985..00000000 --- a/packages/graph-framework/schema/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createFunctions } from "./create-functions.js"; -export { type } from "./types.js"; diff --git a/packages/graph-framework/schema/indext.test.ts b/packages/graph-framework/schema/indext.test.ts deleted file mode 100644 index 5e1c650d..00000000 --- a/packages/graph-framework/schema/indext.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect, test } from "vitest"; -import { createFunctions, type as t } from "./index.js"; - -test("test schema", () => { - const { createEntity } = createFunctions({ - attributes: { - name: t.Text, - age: t.Number, - isActive: t.Checkbox, - email: t.Text, - }, - types: { - Person: ["name", "age"], - User: ["name", "email", "isActive"], - }, - }); - - // Creating an entity combining 'Person' and 'User' types - const personUser = createEntity({ - types: ["Person", "User"], - data: { - name: "Alice", - age: 30, - email: "alice@example.com", - isActive: true, - }, - }); - - expect(personUser).toStrictEqual({ - name: "Alice", - age: 30, - email: "alice@example.com", - isActive: true, - }); -}); diff --git a/packages/graph-framework/schema/types.ts b/packages/graph-framework/schema/types.ts index 4b8aad35..f557dc40 100644 --- a/packages/graph-framework/schema/types.ts +++ b/packages/graph-framework/schema/types.ts @@ -4,5 +4,4 @@ export const type = { Text: S.String, Number: S.Number, Checkbox: S.Boolean, - // ... uri, time }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e127e8ec..a0476a52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: react: specifier: ^18 version: 18.3.1 + uuid: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@automerge/automerge': specifier: ^2.2.8 @@ -254,6 +257,9 @@ importers: '@types/react': specifier: ^18.3.3 version: 18.3.7 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 vitest: specifier: ^2.1.1 version: 2.1.1(@types/node@22.5.5)