From 52869bf19d5dd9efa635fc7b9ef731b88d3f681e Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 24 Oct 2025 23:50:48 +0000 Subject: [PATCH 001/125] Add api-graph --- lint-staged.config.js | 1 + package-lock.json | 20 ++++++ package.json | 4 ++ packages/api-graph/.eslintrc.yml | 14 ++++ packages/api-graph/.gitignore | 4 ++ packages/api-graph/README.md | 0 packages/api-graph/package.json | 64 +++++++++++++++++++ packages/api-graph/src/index.ts | 1 + .../api-graph/src/private/useNodeObject.ts | 5 ++ packages/api-graph/src/tsconfig.json | 3 + packages/api-graph/tsup.config.ts | 23 +++++++ packages/api/graph.js | 3 + packages/api/package.json | 2 + packages/api/src/boot/graph.ts | 1 + packages/api/tsup.config.ts | 1 + 15 files changed, 146 insertions(+) create mode 100644 packages/api-graph/.eslintrc.yml create mode 100644 packages/api-graph/.gitignore create mode 100644 packages/api-graph/README.md create mode 100644 packages/api-graph/package.json create mode 100644 packages/api-graph/src/index.ts create mode 100644 packages/api-graph/src/private/useNodeObject.ts create mode 100644 packages/api-graph/src/tsconfig.json create mode 100644 packages/api-graph/tsup.config.ts create mode 100644 packages/api/graph.js create mode 100644 packages/api/src/boot/graph.ts diff --git a/lint-staged.config.js b/lint-staged.config.js index 1785a33a54..35eb7f14f7 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -12,6 +12,7 @@ module.exports = { '**/*.md': prettierMarkdown, 'packages/**/*.css': ['npm run precommit:biome'], 'packages/api/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:api'], + 'packages/api-graph/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:api-graph'], 'packages/api-middleware/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:api-middleware'], 'packages/base/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:base'], 'packages/bundle/src/**/*.{mjs,js,ts,tsx}': ['npm run precommit:eslint:bundle'], diff --git a/package-lock.json b/package-lock.json index 3a37b61acd..bb4f9c4c3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "./packages/styles", "./packages/support/cldr-data-downloader", "./packages/support/cldr-data", + "./packages/api-graph", "./packages/api-middleware", "./packages/api", "./packages/isomorphic-react", @@ -3033,6 +3034,10 @@ "resolved": "packages/repack/botframework-directlinejs", "link": true }, + "node_modules/@msinternal/botframework-webchat-api-graph": { + "resolved": "packages/api-graph", + "link": true + }, "node_modules/@msinternal/botframework-webchat-api-middleware": { "resolved": "packages/api-middleware", "link": true @@ -18655,6 +18660,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@msinternal/botframework-webchat-api-graph": "0.0.0-0", "@msinternal/botframework-webchat-api-middleware": "0.0.0-0", "@msinternal/botframework-webchat-base": "0.0.0-0", "@msinternal/botframework-webchat-cldr-data": "0.0.0-0", @@ -18681,6 +18687,20 @@ "react-dom": ">= 16.8.6" } }, + "packages/api-graph": { + "name": "@msinternal/botframework-webchat-api-graph", + "version": "0.0.0-0", + "license": "MIT", + "dependencies": { + "valibot": "1.1.0" + }, + "devDependencies": { + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } + }, "packages/api-middleware": { "name": "@msinternal/botframework-webchat-api-middleware", "version": "0.0.0-0", diff --git a/package.json b/package.json index d80e4983b8..bc1911251a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "./packages/styles", "./packages/support/cldr-data-downloader", "./packages/support/cldr-data", + "./packages/api-graph", "./packages/api-middleware", "./packages/api", "./packages/isomorphic-react", @@ -72,6 +73,7 @@ "precommit": "npm run precommit --if-present --workspaces", "precommit:biome": "npm run biome -- --no-errors-on-unmatched", "precommit:eslint:api": "cd packages && cd api && npm run precommit:eslint", + "precommit:eslint:api-graph": "cd packages && cd api-graph && npm run precommit:eslint", "precommit:eslint:api-middleware": "cd packages && cd api-middleware && npm run precommit:eslint", "precommit:eslint:base": "cd packages && cd base && npm run precommit:eslint", "precommit:eslint:bundle": "cd packages && cd bundle && npm run precommit:eslint", @@ -106,6 +108,7 @@ "precommit:eslint:web-server": "cd packages && cd test && cd web-server && npm run precommit:eslint", "precommit:typecheck": "concurrently \"npm:precommit:typecheck:*\"", "precommit:typecheck:api": "cd packages && cd api && npm run precommit:typecheck", + "precommit:typecheck:api-graph": "cd packages && cd api-graph && npm run precommit:typecheck", "precommit:typecheck:api-middleware": "cd packages && cd api-middleware && npm run precommit:typecheck", "precommit:typecheck:base": "cd packages && cd base && npm run precommit:typecheck", "precommit:typecheck:bundle": "cd packages && cd bundle && npm run precommit:typecheck", @@ -132,6 +135,7 @@ "prepare": "husky", "start": "cross-env NODE_OPTIONS=--no-deprecation concurrently --kill-others --prefix-colors \"auto\" \"npm:start:*\"", "start:api": "cd packages && cd api && npm start", + "start:api-graph": "cd packages && cd api-graph && npm start", "start:api-middleware": "cd packages && cd api-middleware && npm start", "start:base": "cd packages && cd base && npm start", "start:bundle": "cd packages && cd bundle && npm start", diff --git a/packages/api-graph/.eslintrc.yml b/packages/api-graph/.eslintrc.yml new file mode 100644 index 0000000000..6ba5244054 --- /dev/null +++ b/packages/api-graph/.eslintrc.yml @@ -0,0 +1,14 @@ +extends: + - ../../.eslintrc.production.yml + - ../../.eslintrc.react.yml + +# This package is compatible with web browser. +env: + browser: true + +rules: + # React functional component is better in function style than arrow style. + prefer-arrow-callback: off + + # React already deprecated default props. + react/require-default-props: off diff --git a/packages/api-graph/.gitignore b/packages/api-graph/.gitignore new file mode 100644 index 0000000000..52536cb56d --- /dev/null +++ b/packages/api-graph/.gitignore @@ -0,0 +1,4 @@ +/*.tgz +/dist/ +/node_modules/ +/tsup.config.bundled_*.mjs diff --git a/packages/api-graph/README.md b/packages/api-graph/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/api-graph/package.json b/packages/api-graph/package.json new file mode 100644 index 0000000000..67ec403946 --- /dev/null +++ b/packages/api-graph/package.json @@ -0,0 +1,64 @@ +{ + "name": "@msinternal/botframework-webchat-api-graph", + "version": "0.0.0-0", + "description": "botframework-webchat-api/graph package", + "main": "./dist/botframework-webchat-api-graph.js", + "types": "./dist/botframework-webchat-api-graph.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/botframework-webchat-api-graph.d.mts", + "default": "./dist/botframework-webchat-api-graph.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-api-graph.d.ts", + "default": "./dist/botframework-webchat-api-graph.js" + } + } + }, + "author": "Microsoft Corporation", + "license": "MIT", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/BotFramework-WebChat.git" + }, + "bugs": { + "url": "https://github.com/microsoft/BotFramework-WebChat/issues" + }, + "files": [ + "./dist/**/*", + "./src/**/*", + "*.js" + ], + "homepage": "https://github.com/microsoft/BotFramework-WebChat/tree/main/packages/api-graph#readme", + "scripts": { + "build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post", + "build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch", + "build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh", + "build:pre:watch": "../../scripts/npm/build-watch.sh", + "build:run": "tsup", + "bump": "npm run bump:prod && npm run bump:dev && npm run bump:peer && (npm audit fix || exit 0)", + "bump:dev": "../../scripts/npm/bump-dev.sh", + "bump:peer": "../../scripts/npm/bump-peer.sh", + "bump:prod": "../../scripts/npm/bump-prod.sh", + "eslint": "npm run precommit", + "postversion": "node ../../scripts/npm/postversion.sh", + "precommit": "npm run precommit:eslint -- src && npm run precommit:typecheck", + "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", + "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", + "preversion": "node ../../scripts/npm/preversion.sh", + "start": "../../scripts/npm/notify-build.sh \"src\"" + }, + "pinDependencies": {}, + "localDependencies": {}, + "devDependencies": { + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">= 16.8.6" + }, + "dependencies": { + "valibot": "1.1.0" + } +} diff --git a/packages/api-graph/src/index.ts b/packages/api-graph/src/index.ts new file mode 100644 index 0000000000..72bf646b69 --- /dev/null +++ b/packages/api-graph/src/index.ts @@ -0,0 +1 @@ +export { default as useNodeObject } from './private/useNodeObject'; diff --git a/packages/api-graph/src/private/useNodeObject.ts b/packages/api-graph/src/private/useNodeObject.ts new file mode 100644 index 0000000000..d5e4fd7f7d --- /dev/null +++ b/packages/api-graph/src/private/useNodeObject.ts @@ -0,0 +1,5 @@ +// Related to https://www.w3.org/TR/json-ld11/#node-objects. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function useNodeObject(_id: string): undefined { + return undefined; +} diff --git a/packages/api-graph/src/tsconfig.json b/packages/api-graph/src/tsconfig.json new file mode 100644 index 0000000000..d0fab377e4 --- /dev/null +++ b/packages/api-graph/src/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@msinternal/botframework-webchat-tsconfig/current" +} diff --git a/packages/api-graph/tsup.config.ts b/packages/api-graph/tsup.config.ts new file mode 100644 index 0000000000..4353e7f9a0 --- /dev/null +++ b/packages/api-graph/tsup.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsup'; + +import { applyConfig } from '../../tsup.base.config'; + +const commonConfig = applyConfig(config => ({ + ...config, + entry: { + 'botframework-webchat-api-graph': './src/index.ts' + } +})); + +export default defineConfig([ + { + ...commonConfig, + format: 'esm', + onSuccess: 'touch ./package.json' + }, + { + ...commonConfig, + format: 'cjs', + target: [...commonConfig.target, 'es2019'] + } +]); diff --git a/packages/api/graph.js b/packages/api/graph.js new file mode 100644 index 0000000000..58cbf2668c --- /dev/null +++ b/packages/api/graph.js @@ -0,0 +1,3 @@ +// This is required for Webpack 4 which does not support named exports. +// eslint-disable-next-line no-undef +module.exports = require('./dist/botframework-webchat-api.graph.js'); diff --git a/packages/api/package.json b/packages/api/package.json index d935f0a5a1..710f08ca4c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -110,6 +110,7 @@ ] }, "localDependencies": { + "@msinternal/botframework-webchat-api-graph": "development", "@msinternal/botframework-webchat-api-middleware": "development", "@msinternal/botframework-webchat-base": "development", "@msinternal/botframework-webchat-cldr-data": "development", @@ -124,6 +125,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@msinternal/botframework-webchat-api-graph": "0.0.0-0", "@msinternal/botframework-webchat-api-middleware": "0.0.0-0", "@msinternal/botframework-webchat-base": "0.0.0-0", "@msinternal/botframework-webchat-cldr-data": "0.0.0-0", diff --git a/packages/api/src/boot/graph.ts b/packages/api/src/boot/graph.ts new file mode 100644 index 0000000000..c9e3e7c27f --- /dev/null +++ b/packages/api/src/boot/graph.ts @@ -0,0 +1 @@ +export { useNodeObject } from '@msinternal/botframework-webchat-api-graph'; diff --git a/packages/api/tsup.config.ts b/packages/api/tsup.config.ts index fb023e1519..ef99ac1eed 100644 --- a/packages/api/tsup.config.ts +++ b/packages/api/tsup.config.ts @@ -7,6 +7,7 @@ const commonConfig = applyConfig(config => ({ entry: { 'botframework-webchat-api': './src/index.ts', 'botframework-webchat-api.decorator': './src/boot/decorator.ts', + 'botframework-webchat-api.graph': './src/boot/graph.ts', 'botframework-webchat-api.hook': './src/boot/hook.ts', 'botframework-webchat-api.internal': './src/boot/internal.ts', 'botframework-webchat-api.middleware': './src/boot/middleware.ts' From 131dd7c65354d8093e309805a745470d351db437 Mon Sep 17 00:00:00 2001 From: William Wong Date: Sat, 25 Oct 2025 16:44:32 +0000 Subject: [PATCH 002/125] Add JSON-LD schemas --- package-lock.json | 24 +++++++++ packages/api-graph/package.json | 3 ++ .../api-graph/src/private/GraphContext.ts | 24 +++++++++ .../api-graph/src/private/GraphProvider.tsx | 27 ++++++++++ .../private/schemas/BlankNodeIdentifier.ts | 20 ++++++++ .../src/private/schemas/Identifier.ts | 18 +++++++ .../api-graph/src/private/schemas/Literal.ts | 15 ++++++ .../src/private/schemas/NodeObject.spec.ts | 27 ++++++++++ .../src/private/schemas/NodeObject.ts | 51 +++++++++++++++++++ .../src/private/schemas/NodeReference.ts | 22 ++++++++ .../src/private/schemas/private/freeze.ts | 12 +++++ .../api-graph/src/private/useNodeObject.ts | 11 ++-- 12 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 packages/api-graph/src/private/GraphContext.ts create mode 100644 packages/api-graph/src/private/GraphProvider.tsx create mode 100644 packages/api-graph/src/private/schemas/BlankNodeIdentifier.ts create mode 100644 packages/api-graph/src/private/schemas/Identifier.ts create mode 100644 packages/api-graph/src/private/schemas/Literal.ts create mode 100644 packages/api-graph/src/private/schemas/NodeObject.spec.ts create mode 100644 packages/api-graph/src/private/schemas/NodeObject.ts create mode 100644 packages/api-graph/src/private/schemas/NodeReference.ts create mode 100644 packages/api-graph/src/private/schemas/private/freeze.ts diff --git a/package-lock.json b/package-lock.json index bb4f9c4c3e..c82e228493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3462,6 +3462,15 @@ "type-detect": "4.0.8" } }, + "node_modules/@testduet/given-when-then": { + "version": "0.1.1-main.28754e6", + "resolved": "https://registry.npmjs.org/@testduet/given-when-then/-/given-when-then-0.1.1-main.28754e6.tgz", + "integrity": "sha512-l75Xw2+vGSDf4qye4WO41IutGUmzllVoSpUyXwAShMAnJf7UHjJEbBKEq8ioGuuL3/1QTqJAOUxJPBmtl5Xhhw==", + "dev": true, + "dependencies": { + "@testduet/given-when-then": "^0.1.1-main.28754e6" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "dev": true, @@ -18695,12 +18704,27 @@ "valibot": "1.1.0" }, "devDependencies": { + "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", + "type-fest": "^4.41.0", "typescript": "^5.7.3" }, "peerDependencies": { "react": ">= 16.8.6" } }, + "packages/api-graph/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/api-middleware": { "name": "@msinternal/botframework-webchat-api-middleware", "version": "0.0.0-0", diff --git a/packages/api-graph/package.json b/packages/api-graph/package.json index 67ec403946..b3f17db75b 100644 --- a/packages/api-graph/package.json +++ b/packages/api-graph/package.json @@ -53,6 +53,9 @@ "pinDependencies": {}, "localDependencies": {}, "devDependencies": { + "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", + "type-fest": "^4.41.0", "typescript": "^5.7.3" }, "peerDependencies": { diff --git a/packages/api-graph/src/private/GraphContext.ts b/packages/api-graph/src/private/GraphContext.ts new file mode 100644 index 0000000000..0ae8da03a3 --- /dev/null +++ b/packages/api-graph/src/private/GraphContext.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react'; +import { map, object, pipe, readonly, string, type InferOutput } from 'valibot'; + +import { nodeObject } from './schemas/NodeObject'; + +const graphContextSchema = pipe( + object({ + objects: pipe(map(string(), nodeObject()), readonly()) + }), + readonly() +); + +type GraphContextType = InferOutput; + +const GraphContext = createContext({ + objects: Object.freeze(new Map()) +}); + +function useGraphContext(): GraphContextType { + return useContext(GraphContext); +} + +export default GraphContext; +export { graphContextSchema, useGraphContext, type GraphContextType }; diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx new file mode 100644 index 0000000000..0ed1bb13f7 --- /dev/null +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -0,0 +1,27 @@ +import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { useMemo } from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +import GraphContext from './GraphContext'; + +const graphProviderPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type GraphProviderProps = InferInput; + +function GraphProvider(props: GraphProviderProps) { + const { children } = validateProps(graphProviderPropsSchema, props); + + const context = useMemo(() => Object.freeze({ objects: Object.freeze(new Map()) }), []); + + return {children}; +} + +GraphProvider.displayName = 'GraphProvider'; + +export default GraphProvider; +export { graphProviderPropsSchema, type GraphProviderProps }; diff --git a/packages/api-graph/src/private/schemas/BlankNodeIdentifier.ts b/packages/api-graph/src/private/schemas/BlankNodeIdentifier.ts new file mode 100644 index 0000000000..f85ae738cf --- /dev/null +++ b/packages/api-graph/src/private/schemas/BlankNodeIdentifier.ts @@ -0,0 +1,20 @@ +import { pipe, safeParse, startsWith, string, type ErrorMessage, type GenericSchema, type InferOutput } from 'valibot'; + +/** + * Schema of JSON-LD blank node identifier. Must be prefixed with `_:`. + * + * @param message + * @returns + */ +function blankNodeIdentifier>(message?: TMessage | undefined) { + return pipe(string(message), startsWith('_:', message)) as GenericSchema<`_:${string}`>; +} + +type BlankNodeIdentifier = InferOutput>; + +function isBlankNodeIdentifier(identifier: string): identifier is BlankNodeIdentifier { + return safeParse(blankNodeIdentifier(), identifier).success; +} + +export default blankNodeIdentifier; +export { isBlankNodeIdentifier, type BlankNodeIdentifier }; diff --git a/packages/api-graph/src/private/schemas/Identifier.ts b/packages/api-graph/src/private/schemas/Identifier.ts new file mode 100644 index 0000000000..9f66e0b063 --- /dev/null +++ b/packages/api-graph/src/private/schemas/Identifier.ts @@ -0,0 +1,18 @@ +import { pipe, string, union, url, type ErrorMessage, type GenericSchema, type InferOutput } from 'valibot'; + +import blankNodeIdentifier from './BlankNodeIdentifier'; + +/** + * Schema of JSON-LD identifier (`@id`). Must be either IRI or blank node identifier (prefixed with `_:`). + * + * @param message + * @returns + */ +function identifier>(message?: TMessage | undefined) { + return union([blankNodeIdentifier(), pipe(string(), url()) as GenericSchema<`https://${string}`>], message); +} + +type Identifier = InferOutput>; + +export default identifier; +export { type Identifier }; diff --git a/packages/api-graph/src/private/schemas/Literal.ts b/packages/api-graph/src/private/schemas/Literal.ts new file mode 100644 index 0000000000..f766cf61c9 --- /dev/null +++ b/packages/api-graph/src/private/schemas/Literal.ts @@ -0,0 +1,15 @@ +import { boolean, null_, number, string, union, type ErrorMessage, type InferOutput } from 'valibot'; + +/** + * Schema of JSON-LD literals. + * + * @param message + * @returns + */ +function literal>(message?: TMessage | undefined) { + return union([boolean(), null_(), number(), string()], message); +} + +type Literal = InferOutput>; + +export { literal, type Literal }; diff --git a/packages/api-graph/src/private/schemas/NodeObject.spec.ts b/packages/api-graph/src/private/schemas/NodeObject.spec.ts new file mode 100644 index 0000000000..ee7ff575cb --- /dev/null +++ b/packages/api-graph/src/private/schemas/NodeObject.spec.ts @@ -0,0 +1,27 @@ +/// + +import { scenario } from '@testduet/given-when-then'; +import { parse } from 'valibot'; +import { nodeObject } from './NodeObject'; + +scenario('NodeObject', bdd => { + bdd.given + .oneOf([ + ['a boolean', () => [true, [true]]], + ['a number', () => [1, [1]]], + ['a string', () => ['abc', ['abc']]], + ['a null value', () => [null, [null]]], + ['a node object', () => [{ '@id': '_:2', value: 123 }, [{ '@id': '_:2', value: [123] }]]], + ['an array of boolean', () => [[true], [true]]], + ['an array of number', () => [[1], [1]]], + ['an array of string', () => [['abc'], ['abc']]], + ['an array of null value', () => [[null], [null]]], + ['an array of node object', () => [[{ '@id': '_:2', value: 123 }], [{ '@id': '_:2', value: [123] }]]] + ]) + .when('parsing value in a NodeObject', ([value]) => parse(nodeObject(), { '@id': '_:1', value })) + .then('should return expected value', ([_, expected], { value }) => expect(value).toEqual(expected)) + .and('node object should be frozen', (_, nodeObject) => expect(Object.isFrozen(nodeObject)).toBe(true)) + .and('value should be frozen', (_, nodeObject) => { + expect(Object.isFrozen(nodeObject['value'])).toBe(true); + }); +}); diff --git a/packages/api-graph/src/private/schemas/NodeObject.ts b/packages/api-graph/src/private/schemas/NodeObject.ts new file mode 100644 index 0000000000..16b9748546 --- /dev/null +++ b/packages/api-graph/src/private/schemas/NodeObject.ts @@ -0,0 +1,51 @@ +import { array, lazy, objectWithRest, pipe, transform, union, type ErrorMessage, type GenericSchema } from 'valibot'; + +import identifier, { type Identifier } from './Identifier'; +import { literal, type Literal } from './Literal'; +import freeze from './private/freeze'; + +type Input = { + '@id': Identifier; +} & { + [key: string]: Literal | Input | (Literal | Input)[]; +}; + +type NodeObject = { + readonly '@id': Identifier; +} & { + readonly [key: string]: readonly (Literal | NodeObject)[]; +}; + +/** + * Schema of JSON-LD node object. + * + * When parsed, all property value will be wrapped in an array. + * + * @param message + * @returns + */ +function nodeObject>( + message?: TMessage | undefined +): GenericSchema { + return pipe( + objectWithRest( + { '@id': identifier() }, + union([ + pipe( + literal(), + transform(value => Object.freeze([value])) + ), + pipe(array(literal()), freeze()), + pipe( + lazy(() => nodeObject()), + transform(value => Object.freeze([value])) + ), + pipe(array(lazy(() => nodeObject())), freeze()) + ]), + message + ), + freeze() + ); +} + +export { nodeObject, type NodeObject }; diff --git a/packages/api-graph/src/private/schemas/NodeReference.ts b/packages/api-graph/src/private/schemas/NodeReference.ts new file mode 100644 index 0000000000..7dc0918ab7 --- /dev/null +++ b/packages/api-graph/src/private/schemas/NodeReference.ts @@ -0,0 +1,22 @@ +import { pipe, safeParse, strictObject, type ErrorMessage, type InferOutput } from 'valibot'; + +import identifier from './Identifier'; +import freeze from './private/freeze'; + +/** + * Schema of JSON-LD node reference. A node reference is an object with only `@id` and nothing else. + * + * @param message + * @returns + */ +function nodeReference>(message?: TMessage | undefined) { + return pipe(strictObject({ '@id': identifier() }, message), freeze()); +} + +type NodeReference = InferOutput>; + +function isNodeReference(nodeObject: NodeReference): nodeObject is NodeReference { + return safeParse(nodeReference(), nodeObject).success; +} + +export { isNodeReference, nodeReference, type NodeReference }; diff --git a/packages/api-graph/src/private/schemas/private/freeze.ts b/packages/api-graph/src/private/schemas/private/freeze.ts new file mode 100644 index 0000000000..e5de0f3e7e --- /dev/null +++ b/packages/api-graph/src/private/schemas/private/freeze.ts @@ -0,0 +1,12 @@ +import { transform } from 'valibot'; + +/** + * Valibot pipe action to freeze the array/object. The value will not be cloned before freeze. + * + * The `readonly()` pipe action in Valibot does not freeze. + * + * @returns + */ +export default function freeze() { + return transform>(value => Object.freeze(value)); +} diff --git a/packages/api-graph/src/private/useNodeObject.ts b/packages/api-graph/src/private/useNodeObject.ts index d5e4fd7f7d..29ef847603 100644 --- a/packages/api-graph/src/private/useNodeObject.ts +++ b/packages/api-graph/src/private/useNodeObject.ts @@ -1,5 +1,10 @@ +import { useGraphContext } from './GraphContext'; +import type { Identifier } from './schemas/Identifier'; +import type { NodeObject } from './schemas/NodeObject'; + // Related to https://www.w3.org/TR/json-ld11/#node-objects. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export default function useNodeObject(_id: string): undefined { - return undefined; +export default function useNodeObject(id: Identifier): NodeObject | undefined { + const { objects } = useGraphContext(); + + return objects.get(id); } From c9c81b05de4cfbf650758753879d28d6b6874374 Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 21:17:01 +0000 Subject: [PATCH 003/125] Add flattenedNodeObject --- .../api-graph/src/private/GraphContext.ts | 4 +- .../api-graph/src/private/GraphProvider.tsx | 7 +- .../private/schemas/FlattenedNodeObject.ts | 44 ++++ .../api-graph/src/private/schemas/Literal.ts | 8 +- .../src/private/schemas/NodeObject.ts | 37 ++- .../private/schemas/expectExtendValibot.ts | 26 +++ .../private/schemas/flattenNodeObject.spec.ts | 210 ++++++++++++++++++ .../src/private/schemas/flattenNodeObject.ts | 112 ++++++++++ 8 files changed, 433 insertions(+), 15 deletions(-) create mode 100644 packages/api-graph/src/private/schemas/FlattenedNodeObject.ts create mode 100644 packages/api-graph/src/private/schemas/expectExtendValibot.ts create mode 100644 packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts create mode 100644 packages/api-graph/src/private/schemas/flattenNodeObject.ts diff --git a/packages/api-graph/src/private/GraphContext.ts b/packages/api-graph/src/private/GraphContext.ts index 0ae8da03a3..fa57fd662b 100644 --- a/packages/api-graph/src/private/GraphContext.ts +++ b/packages/api-graph/src/private/GraphContext.ts @@ -5,7 +5,7 @@ import { nodeObject } from './schemas/NodeObject'; const graphContextSchema = pipe( object({ - objects: pipe(map(string(), nodeObject()), readonly()) + graph: pipe(map(string(), nodeObject()), readonly()) }), readonly() ); @@ -13,7 +13,7 @@ const graphContextSchema = pipe( type GraphContextType = InferOutput; const GraphContext = createContext({ - objects: Object.freeze(new Map()) + graph: Object.freeze(new Map()) }); function useGraphContext(): GraphContextType { diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx index 0ed1bb13f7..939c24746a 100644 --- a/packages/api-graph/src/private/GraphProvider.tsx +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -1,8 +1,9 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import GraphContext from './GraphContext'; +import type { NodeObject } from './schemas/NodeObject'; const graphProviderPropsSchema = pipe( object({ @@ -16,7 +17,9 @@ type GraphProviderProps = InferInput; function GraphProvider(props: GraphProviderProps) { const { children } = validateProps(graphProviderPropsSchema, props); - const context = useMemo(() => Object.freeze({ objects: Object.freeze(new Map()) }), []); + const [graph, setGraph] = useState>(() => Object.freeze(new Map())); + + const context = useMemo(() => Object.freeze({ graph }), [graph]); return {children}; } diff --git a/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts b/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts new file mode 100644 index 0000000000..acba579031 --- /dev/null +++ b/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts @@ -0,0 +1,44 @@ +import { array, objectWithRest, optional, pipe, string, union, type ErrorMessage, type InferOutput } from 'valibot'; +import identifier from './Identifier'; +import { literal } from './Literal'; +import { nodeReference } from './NodeReference'; +import freeze from './private/freeze'; + +// type FlattenedNodeObjectPropertyValue = Literal | NodeReference | readonly (Literal | NodeReference)[]; + +function flattenedNodeObjectPropertyValue>(message?: TMessage | undefined) { + return union( + [pipe(array(union([literal(), nodeReference()])), freeze()), pipe(literal()), pipe(nodeReference())], + message + ); +} + +type FlattenedNodeObjectPropertyValue = InferOutput>; + +/** + * Schema of JSON-LD node object. + * + * When parsed, all property value will be wrapped in an array. + * + * @param message + * @returns + */ +function flattenedNodeObject>(message?: TMessage | undefined) { + return pipe( + objectWithRest( + { + '@context': optional(string()), + '@id': identifier(), + '@type': optional(string()) + }, + flattenedNodeObjectPropertyValue(), + message + ), + freeze() + ); +} + +type FlattenedNodeObject = InferOutput>; + +export default flattenedNodeObject; +export { flattenedNodeObjectPropertyValue, type FlattenedNodeObject, type FlattenedNodeObjectPropertyValue }; diff --git a/packages/api-graph/src/private/schemas/Literal.ts b/packages/api-graph/src/private/schemas/Literal.ts index f766cf61c9..96e54239ff 100644 --- a/packages/api-graph/src/private/schemas/Literal.ts +++ b/packages/api-graph/src/private/schemas/Literal.ts @@ -1,4 +1,4 @@ -import { boolean, null_, number, string, union, type ErrorMessage, type InferOutput } from 'valibot'; +import { boolean, null_, number, safeParse, string, union, type ErrorMessage, type InferOutput } from 'valibot'; /** * Schema of JSON-LD literals. @@ -12,4 +12,8 @@ function literal>(message?: TMessage | undefi type Literal = InferOutput>; -export { literal, type Literal }; +function isLiteral(value: unknown): value is Literal { + return safeParse(literal(), value).success; +} + +export { isLiteral, literal, type Literal }; diff --git a/packages/api-graph/src/private/schemas/NodeObject.ts b/packages/api-graph/src/private/schemas/NodeObject.ts index 16b9748546..1a46a10c96 100644 --- a/packages/api-graph/src/private/schemas/NodeObject.ts +++ b/packages/api-graph/src/private/schemas/NodeObject.ts @@ -1,17 +1,32 @@ -import { array, lazy, objectWithRest, pipe, transform, union, type ErrorMessage, type GenericSchema } from 'valibot'; +import { + array, + lazy, + objectWithRest, + optional, + pipe, + string, + transform, + union, + type ErrorMessage, + type GenericSchema +} from 'valibot'; import identifier, { type Identifier } from './Identifier'; import { literal, type Literal } from './Literal'; import freeze from './private/freeze'; type Input = { - '@id': Identifier; + '@context'?: string | undefined; + '@id'?: Identifier | undefined; + '@type'?: string | undefined; } & { [key: string]: Literal | Input | (Literal | Input)[]; }; type NodeObject = { - readonly '@id': Identifier; + readonly '@context'?: string | undefined; + readonly '@id'?: Identifier | undefined; + readonly '@type'?: string | undefined; } & { readonly [key: string]: readonly (Literal | NodeObject)[]; }; @@ -29,18 +44,22 @@ function nodeObject>( ): GenericSchema { return pipe( objectWithRest( - { '@id': identifier() }, + { + '@context': optional(string()), + '@id': optional(identifier()), + '@type': optional(string()) + }, union([ - pipe( - literal(), - transform(value => Object.freeze([value])) - ), + pipe(array(lazy(() => nodeObject())), freeze()), pipe(array(literal()), freeze()), pipe( lazy(() => nodeObject()), transform(value => Object.freeze([value])) ), - pipe(array(lazy(() => nodeObject())), freeze()) + pipe( + literal(), + transform(value => Object.freeze([value])) + ) ]), message ), diff --git a/packages/api-graph/src/private/schemas/expectExtendValibot.ts b/packages/api-graph/src/private/schemas/expectExtendValibot.ts new file mode 100644 index 0000000000..558c26475d --- /dev/null +++ b/packages/api-graph/src/private/schemas/expectExtendValibot.ts @@ -0,0 +1,26 @@ +import { expect } from '@jest/globals'; +import { assert, type BaseSchema } from 'valibot'; + +declare module 'expect' { + interface AsymmetricMatchers { + valibot(schema: BaseSchema): any; + } +} + +expect.extend({ + valibot: (actual, schema) => { + try { + assert(schema, actual); + } catch (error) { + return { + message: (): string => + error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' + ? error.message + : '', + pass: false + }; + } + + return { message: () => '', pass: true }; + } +}); diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts new file mode 100644 index 0000000000..823c81447f --- /dev/null +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -0,0 +1,210 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { array, assert, length, object, pipe } from 'valibot'; + +import blankNodeIdentifier from './BlankNodeIdentifier'; +import './expectExtendValibot'; +import flattenNodeObject from './flattenNodeObject'; +import identifier from './Identifier'; +import type { Literal } from './Literal'; +import { nodeObject } from './NodeObject'; +import { nodeReference } from './NodeReference'; + +scenario('flattenNodeObject()', bdd => { + bdd.given + .oneOf([ + [ + 'a simple non-JSON-LD object with a property with value of type "number"', + () => [{ value: 123 }, { value: 123 }] + ], + [ + 'a simple non-JSON-LD object with a property with value of type "string"', + () => [{ value: 'abc' }, { value: 'abc' }] + ], + [ + 'a simple non-JSON-LD object with a property with value of type "boolean"', + () => [{ value: true }, { value: true }] + ], + [ + 'a simple non-JSON-LD object with a property with value of type "null"', + () => [{ value: null }, { value: null }] + ] + ]) + .when('flattened', ([value]) => flattenNodeObject(value)) + .then('should return a node reference', (_, { output }) => { + assert(nodeReference(), output); + assert(blankNodeIdentifier(), output['@id']); + }) + .and('should return a graph with one node object', (_, { graph }) => { + assert(pipe(array(nodeObject()), length(1)), graph); + }) + .and('should return a graph with the node object', ([_, expected], { graph, output }) => { + expect(graph).toEqual([{ ...expected, '@id': output['@id'] }]); + }); + + bdd + .given('a simple JSON-LD object with `@id` of `_:v1`', () => ({ '@id': '_:v1', value: 123 })) + .when('flattened', value => flattenNodeObject(value)) + .then('should return a node reference with `@id` of `_:v1`', (_, { output }) => { + expect(output).toEqual({ '@id': '_:v1' }); + }) + .and('should return a graph with the node object with `@id` of `_:v1`', (_, { graph }) => { + expect(graph).toEqual([{ '@id': '_:v1', value: 123 }]); + }); + + bdd + .given('an object with a child object', () => ({ + description: 'The Empire State Building is a 102-story landmark in New York City.', + geo: { + latitude: '40.75', + longitude: '73.98' + }, + image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', + name: 'The Empire State Building' + })) + .when('flattened', value => flattenNodeObject(value)) + .then( + 'should return a graph with 2 node objects and all property values are encapsulated in array', + (_, { graph }) => { + expect(graph).toEqual([ + { + '@id': expect.valibot(identifier()), + description: 'The Empire State Building is a 102-story landmark in New York City.', + geo: { '@id': expect.valibot(identifier()) }, + image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', + name: 'The Empire State Building' + }, + { + '@id': expect.valibot(identifier()), + latitude: '40.75', + longitude: '73.98' + } + ]); + } + ) + .and('the first object must be the root object', (_, { output, graph }) => { + expect(graph[0]?.['@id']).toBe(output['@id']); + }) + .and('the root object should reference the geo object', (_, { output, graph }) => { + const rootObject = graph.find(object => object['@id'] === output['@id']); + + assert(object({ geo: nodeReference() }), rootObject); + + const geoObject = graph.find(object => object !== rootObject); + + assert(nodeObject(), geoObject); + + expect(rootObject.geo['@id']).toBe(geoObject['@id']); + }); + + bdd + .given( + 'spying console.warn', + // eslint-disable-next-line @typescript-eslint/no-empty-function + () => jest.spyOn(console, 'warn').mockImplementation(() => {}), + warn => warn.mockRestore() + ) + .and('a Conversation object from Schema.org with `@id` added for cross-referencing', () => ({ + '@context': 'https://schema.org/', + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [ + { + '@type': 'Message', + sender: { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, + recipient: { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, + about: { '@type': 'Thing', name: 'Duck Season' }, + datePublished: '2016-02-29' + }, + { + '@type': 'Message', + sender: { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, + recipient: { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, + about: { '@type': 'Thing', name: 'Rabbit Season' }, + datePublished: '2016-03-01' + } + ] + })) + .when('when flattened', value => flattenNodeObject(value)) + .then('the first object should be the Conversation', (_, { graph }) => { + expect(graph[0]).toEqual({ + '@context': 'https://schema.org/', + '@id': expect.valibot(identifier()), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] + }); + }) + .and('should return 7 objects', (_, { graph }) => { + expect(graph).toHaveLength(7); + }) + .and('should warn about adding objects twice', () => { + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenNthCalledWith(1, 'Object [@id="_:daffy-duck"] has already added to the graph.'); + expect(console.warn).toHaveBeenNthCalledWith(2, 'Object [@id="_:bugs-bunny"] has already added to the graph.'); + }); + + bdd + .given( + 'spying console.warn', + () => jest.spyOn(console, 'warn'), + warn => warn.mockRestore() + ) + .and('a Conversation object from Schema.org', () => { + const bugsBunny = { '@type': 'Person', name: 'Bugs Bunny' }; + const daffyDuck = { '@type': 'Person', name: 'Daffy Duck' }; + + return { + '@context': 'https://schema.org/', + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [ + { + '@type': 'Message', + sender: bugsBunny, + recipient: daffyDuck, + about: { '@type': 'Thing', name: 'Duck Season' }, + datePublished: '2016-02-29' + }, + { + '@type': 'Message', + sender: daffyDuck, + recipient: bugsBunny, + about: { '@type': 'Thing', name: 'Rabbit Season' }, + datePublished: '2016-03-01' + } + ] + }; + }) + .when('when flattened', value => flattenNodeObject(value)) + .then('the first object should be the Conversation', (_, { graph }) => { + expect(graph[0]).toEqual({ + '@context': 'https://schema.org/', + '@id': expect.valibot(identifier()), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] + }); + }) + .and('should return 7 objects', (_, { graph }) => { + expect(graph).toHaveLength(7); + }) + .and('should not warn', () => { + expect(console.warn).not.toHaveBeenCalled(); + }); + + bdd + .given('a string', () => 'abc') + .when('catching exception from the call', (value): any => { + try { + flattenNodeObject(value as any); + } catch (error) { + return error; + } + }) + .then('should throw', (_, error) => expect(error).toBeTruthy()); +}); diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts new file mode 100644 index 0000000000..d33c079928 --- /dev/null +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -0,0 +1,112 @@ +import { object, optional, parse, safeParse } from 'valibot'; +import type { FlattenedNodeObject, FlattenedNodeObjectPropertyValue } from './FlattenedNodeObject'; +import flattenedNodeObject from './FlattenedNodeObject'; +import identifier from './Identifier'; +import { literal, type Literal } from './Literal'; +import { nodeReference, type NodeReference } from './NodeReference'; + +function flattenNodeObject_( + input: T, + graphMap: Map, + refMap: Map +): T; + +function flattenNodeObject_( + input: object, + graphMap: Map, + refMap: Map +): NodeReference; + +function flattenNodeObject_( + input: Literal | object, + graphMap: Map, + refMap: Map +): Literal | NodeReference; + +function flattenNodeObject_( + input: Literal | object, + graphMap: Map, + refMap: Map +): Literal | NodeReference { + const parseAsLiteralResult = safeParse(literal(), input); + + if (parseAsLiteralResult.success) { + return parseAsLiteralResult.output; + } + + // This is for TypeScript only because safeParse().success is not a type predicate. + input = input as object; + + if (!safeParse(object({}), input).success) { + const error = new Error('Only JSON-LD literal and plain object can be flattened'); + + error.cause = { input }; + + throw error; + } + + const existingNodeReference = refMap.get(input); + + if (existingNodeReference) { + return existingNodeReference; + } + + const id = + parse( + optional(identifier()), + (input && typeof input === 'object' && '@id' in input && input['@id']) || undefined + ) ?? `_:${crypto.randomUUID()}`; + + if (graphMap.get(id)) { + console.warn(`Object [@id="${id}"] has already added to the graph.`); + } + + // We want to reserve the top position for root object. We will set it later. + // @ts-expect-error + graphMap.set(id, undefined); + + const targetMap = new Map(); + + for (const [key, value] of Object.entries(input)) { + let parsedValue: FlattenedNodeObjectPropertyValue; + + if (Array.isArray(value)) { + const resultArray: (Literal | NodeReference)[] = []; + + for (const element of value) { + resultArray.push(flattenNodeObject_(element, graphMap, refMap)); + } + + parsedValue = Object.freeze(resultArray); + } else { + parsedValue = flattenNodeObject_(value, graphMap, refMap); + } + + targetMap.set(key, parsedValue); + } + + // const id = parse(optional(identifier()), targetMap.get('@id')) ?? `_:${crypto.randomUUID()}`; + + targetMap.set('@id', id); + + const output: FlattenedNodeObject = parse(flattenedNodeObject(), Object.fromEntries(Array.from(targetMap))); + const nodeRef = parse(nodeReference(), Object.freeze({ '@id': id })); + + graphMap.set(id, output); + refMap.set(input, nodeRef); + + return nodeRef; +} + +export default function flattenNodeObject(input: object): { + readonly graph: readonly FlattenedNodeObject[]; + readonly output: NodeReference; +} { + parse(object({}), input); + + const graph = new Map(); + const refMap = new Map(); + const output = flattenNodeObject_(input, graph, refMap); + + return { graph: Object.freeze(Array.from(graph.values())), output }; +} From 09f0bfd471c7416a79bb859ede04cf3f332cfaef Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 22:29:31 +0000 Subject: [PATCH 004/125] Fail when flattening array --- .../flattenNodeObject.conversation.spec.ts | 212 ++++++++++++++++++ .../private/schemas/flattenNodeObject.spec.ts | 110 +-------- .../src/private/schemas/flattenNodeObject.ts | 3 +- 3 files changed, 221 insertions(+), 104 deletions(-) create mode 100644 packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts new file mode 100644 index 0000000000..d9f9bcf4bf --- /dev/null +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts @@ -0,0 +1,212 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; + +import './expectExtendValibot'; +import flattenNodeObject from './flattenNodeObject'; +import identifier from './Identifier'; +import { nodeReference } from './NodeReference'; + +scenario('flattenNodeObject()', bdd => { + bdd + .given( + 'spying console.warn', + // eslint-disable-next-line @typescript-eslint/no-empty-function + () => jest.spyOn(console, 'warn').mockImplementation(() => {}), + warn => warn.mockRestore() + ) + .and('a Conversation object from Schema.org with `@id` added for cross-referencing', () => ({ + '@context': 'https://schema.org/', + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [ + { + '@type': 'Message', + sender: { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, + recipient: { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, + about: { '@type': 'Thing', name: 'Duck Season' }, + datePublished: '2016-02-29' + }, + { + '@type': 'Message', + sender: { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, + recipient: { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, + about: { '@type': 'Thing', name: 'Rabbit Season' }, + datePublished: '2016-03-01' + } + ] + })) + .when('when flattened', value => flattenNodeObject(value)) + .then('the first object should be the Conversation', (_, { graph }) => { + expect(graph[0]).toEqual({ + '@context': 'https://schema.org/', + '@id': expect.valibot(identifier()), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] + }); + }) + .and('should return 7 objects', (_, { graph }) => { + expect(graph).toEqual([ + { + '@context': 'https://schema.org/', + '@id': expect.valibot(identifier()), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] + }, + { + '@id': expect.valibot(identifier()), + '@type': 'Message', + sender: { '@id': '_:bugs-bunny' }, + recipient: { '@id': '_:daffy-duck' }, + about: expect.valibot(nodeReference()), + datePublished: '2016-02-29' + }, + { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, + { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, + { + '@id': expect.valibot(identifier()), + '@type': 'Thing', + name: 'Duck Season' + }, + { + '@id': expect.valibot(identifier()), + '@type': 'Message', + sender: { '@id': '_:daffy-duck' }, + recipient: { '@id': '_:bugs-bunny' }, + about: expect.valibot(nodeReference()), + datePublished: '2016-03-01' + }, + { + '@id': expect.valibot(identifier()), + '@type': 'Thing', + name: 'Rabbit Season' + } + ]); + }) + .and('should warn about adding objects twice', () => { + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenNthCalledWith(1, 'Object [@id="_:daffy-duck"] has already added to the graph.'); + expect(console.warn).toHaveBeenNthCalledWith(2, 'Object [@id="_:bugs-bunny"] has already added to the graph.'); + }); + + bdd + .given( + 'spying console.warn', + () => jest.spyOn(console, 'warn'), + warn => warn.mockRestore() + ) + .and('a Conversation object from Schema.org', () => { + const bugsBunny = { '@type': 'Person', name: 'Bugs Bunny' }; + const daffyDuck = { '@type': 'Person', name: 'Daffy Duck' }; + + return { + '@context': 'https://schema.org/', + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [ + { + '@type': 'Message', + sender: bugsBunny, + recipient: daffyDuck, + about: { '@type': 'Thing', name: 'Duck Season' }, + datePublished: '2016-02-29' + }, + { + '@type': 'Message', + sender: daffyDuck, + recipient: bugsBunny, + about: { '@type': 'Thing', name: 'Rabbit Season' }, + datePublished: '2016-03-01' + } + ] + }; + }) + .when('when flattened', value => flattenNodeObject(value)) + .then('the first object should be the Conversation', (_, { graph }) => { + expect(graph[0]).toEqual({ + '@context': 'https://schema.org/', + '@id': expect.valibot(identifier()), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] + }); + }) + .and('should return 7 objects', (_, { graph }) => { + expect(graph).toEqual([ + { + '@context': 'https://schema.org/', + '@id': expect.valibot(identifier()), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] + }, + { + '@id': expect.valibot(identifier()), + '@type': 'Message', + sender: expect.valibot(nodeReference()), + recipient: expect.valibot(nodeReference()), + about: { '@id': expect.valibot(identifier()) }, + datePublished: '2016-02-29' + }, + { '@id': expect.valibot(identifier()), '@type': 'Person', name: 'Bugs Bunny' }, + { '@id': expect.valibot(identifier()), '@type': 'Person', name: 'Daffy Duck' }, + { + '@id': expect.valibot(identifier()), + '@type': 'Thing', + name: 'Duck Season' + }, + { + '@id': expect.valibot(identifier()), + '@type': 'Message', + sender: expect.valibot(nodeReference()), + recipient: expect.valibot(nodeReference()), + about: expect.valibot(nodeReference()), + datePublished: '2016-03-01' + }, + { + '@id': expect.valibot(identifier()), + '@type': 'Thing', + name: 'Rabbit Season' + } + ]); + }) + .and('node reference should be linked properly', (_, { graph }) => { + // Conversation.hasPart = [Message[0], Message[1]] + // @ts-ignore + expect(graph[0]['hasPart']).toEqual([{ '@id': graph[1]['@id'] }, { '@id': graph[5]['@id'] }]); + + // Message[0].sender = Message[name="Bug Bunny"] + // @ts-ignore + expect(graph[1]['sender']['@id']).toEqual(graph[2]['@id']); + + // Message[0].recipient = Message[name="Daffy Duck"] + // @ts-ignore + expect(graph[1]['recipient']['@id']).toEqual(graph[3]['@id']); + + // Message[0].about = Thing[name="Duck Season"] + // @ts-ignore + expect(graph[1]['about']['@id']).toEqual(graph[4]['@id']); + + // Message[1].sender = Message[name="Daffy Duck"] + // @ts-ignore + expect(graph[5]['sender']['@id']).toEqual(graph[3]['@id']); + + // Message[1].recipient = Message[name="Bugs Bunny"] + // @ts-ignore + expect(graph[5]['recipient']['@id']).toEqual(graph[2]['@id']); + + // Message[1].about = Thing[name="Rabbit Season"] + // @ts-ignore + expect(graph[5]['about']['@id']).toEqual(graph[6]['@id']); + }) + .and('should not warn', () => { + expect(console.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts index 823c81447f..c234bf089a 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -70,7 +70,7 @@ scenario('flattenNodeObject()', bdd => { { '@id': expect.valibot(identifier()), description: 'The Empire State Building is a 102-story landmark in New York City.', - geo: { '@id': expect.valibot(identifier()) }, + geo: expect.valibot(nodeReference()), image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', name: 'The Empire State Building' }, @@ -97,108 +97,12 @@ scenario('flattenNodeObject()', bdd => { expect(rootObject.geo['@id']).toBe(geoObject['@id']); }); - bdd - .given( - 'spying console.warn', - // eslint-disable-next-line @typescript-eslint/no-empty-function - () => jest.spyOn(console, 'warn').mockImplementation(() => {}), - warn => warn.mockRestore() - ) - .and('a Conversation object from Schema.org with `@id` added for cross-referencing', () => ({ - '@context': 'https://schema.org/', - '@type': 'Conversation', - name: 'Duck Season vs Rabbit Season', - sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', - hasPart: [ - { - '@type': 'Message', - sender: { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, - recipient: { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, - about: { '@type': 'Thing', name: 'Duck Season' }, - datePublished: '2016-02-29' - }, - { - '@type': 'Message', - sender: { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, - recipient: { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, - about: { '@type': 'Thing', name: 'Rabbit Season' }, - datePublished: '2016-03-01' - } - ] - })) - .when('when flattened', value => flattenNodeObject(value)) - .then('the first object should be the Conversation', (_, { graph }) => { - expect(graph[0]).toEqual({ - '@context': 'https://schema.org/', - '@id': expect.valibot(identifier()), - '@type': 'Conversation', - name: 'Duck Season vs Rabbit Season', - sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', - hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] - }); - }) - .and('should return 7 objects', (_, { graph }) => { - expect(graph).toHaveLength(7); - }) - .and('should warn about adding objects twice', () => { - expect(console.warn).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenNthCalledWith(1, 'Object [@id="_:daffy-duck"] has already added to the graph.'); - expect(console.warn).toHaveBeenNthCalledWith(2, 'Object [@id="_:bugs-bunny"] has already added to the graph.'); - }); - - bdd - .given( - 'spying console.warn', - () => jest.spyOn(console, 'warn'), - warn => warn.mockRestore() - ) - .and('a Conversation object from Schema.org', () => { - const bugsBunny = { '@type': 'Person', name: 'Bugs Bunny' }; - const daffyDuck = { '@type': 'Person', name: 'Daffy Duck' }; - - return { - '@context': 'https://schema.org/', - '@type': 'Conversation', - name: 'Duck Season vs Rabbit Season', - sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', - hasPart: [ - { - '@type': 'Message', - sender: bugsBunny, - recipient: daffyDuck, - about: { '@type': 'Thing', name: 'Duck Season' }, - datePublished: '2016-02-29' - }, - { - '@type': 'Message', - sender: daffyDuck, - recipient: bugsBunny, - about: { '@type': 'Thing', name: 'Rabbit Season' }, - datePublished: '2016-03-01' - } - ] - }; - }) - .when('when flattened', value => flattenNodeObject(value)) - .then('the first object should be the Conversation', (_, { graph }) => { - expect(graph[0]).toEqual({ - '@context': 'https://schema.org/', - '@id': expect.valibot(identifier()), - '@type': 'Conversation', - name: 'Duck Season vs Rabbit Season', - sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', - hasPart: [expect.valibot(nodeReference()), expect.valibot(nodeReference())] - }); - }) - .and('should return 7 objects', (_, { graph }) => { - expect(graph).toHaveLength(7); - }) - .and('should not warn', () => { - expect(console.warn).not.toHaveBeenCalled(); - }); - - bdd - .given('a string', () => 'abc') + bdd.given + .oneOf([ + ['a string', () => 'abc'], // Literal cannot be flattened, only object is allowed. + ['an array of string', () => ['abc'] as any], // Array cannot be flattened, only object is allowed. + ['a MessageChannel object', () => new MessageChannel()] // Complex object cannot be flattened, only plain object is allowed. + ]) .when('catching exception from the call', (value): any => { try { flattenNodeObject(value as any); diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts index d33c079928..79ea87cbac 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -37,7 +37,8 @@ function flattenNodeObject_( // This is for TypeScript only because safeParse().success is not a type predicate. input = input as object; - if (!safeParse(object({}), input).success) { + // Array is allowed by valibot.object({}), we need to check for plain object first. + if (Object.prototype.toString.call(input) !== '[object Object]' || !safeParse(object({}), input).success) { const error = new Error('Only JSON-LD literal and plain object can be flattened'); error.cause = { input }; From f00bda8129eb1417fef30c34716bdad2183efb9a Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 22:30:06 +0000 Subject: [PATCH 005/125] Add more tests for flattening --- .../api-graph/src/private/schemas/flattenNodeObject.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts index c234bf089a..d84d0f5a36 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -101,7 +101,8 @@ scenario('flattenNodeObject()', bdd => { .oneOf([ ['a string', () => 'abc'], // Literal cannot be flattened, only object is allowed. ['an array of string', () => ['abc'] as any], // Array cannot be flattened, only object is allowed. - ['a MessageChannel object', () => new MessageChannel()] // Complex object cannot be flattened, only plain object is allowed. + ['a MessageChannel object', () => new MessageChannel()], // Complex object cannot be flattened, only plain object is allowed. + ['an undefined value', () => undefined] // Undefined cannot be flattened, only null is allowed. ]) .when('catching exception from the call', (value): any => { try { From 80638e4854f74d6a8362e628d77218b977e8d9a8 Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 22:34:20 +0000 Subject: [PATCH 006/125] Expect no warn/error --- .../src/private/schemas/flattenNodeObject.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts index d84d0f5a36..ff33567f54 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -10,6 +10,16 @@ import type { Literal } from './Literal'; import { nodeObject } from './NodeObject'; import { nodeReference } from './NodeReference'; +beforeEach(() => { + jest.spyOn(console, 'error'); + jest.spyOn(console, 'warn'); +}); + +afterEach(() => { + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); +}); + scenario('flattenNodeObject()', bdd => { bdd.given .oneOf([ From a4fb13ed4cedb46123f3e64d8b9904be17cb49f8 Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 22:53:34 +0000 Subject: [PATCH 007/125] Add doc --- .../src/private/schemas/flattenNodeObject.ts | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts index 79ea87cbac..25bd141a74 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -1,32 +1,35 @@ import { object, optional, parse, safeParse } from 'valibot'; + import type { FlattenedNodeObject, FlattenedNodeObjectPropertyValue } from './FlattenedNodeObject'; import flattenedNodeObject from './FlattenedNodeObject'; import identifier from './Identifier'; import { literal, type Literal } from './Literal'; import { nodeReference, type NodeReference } from './NodeReference'; +type FlattenNodeObjectInput = Literal | (object & { '@id'?: string }); + function flattenNodeObject_( input: T, graphMap: Map, - refMap: Map + refMap: Map ): T; function flattenNodeObject_( - input: object, + input: FlattenNodeObjectInput, graphMap: Map, - refMap: Map + refMap: Map ): NodeReference; function flattenNodeObject_( - input: Literal | object, + input: FlattenNodeObjectInput | Literal, graphMap: Map, - refMap: Map + refMap: Map ): Literal | NodeReference; function flattenNodeObject_( - input: Literal | object, + input: FlattenNodeObjectInput | Literal, graphMap: Map, - refMap: Map + refMap: Map ): Literal | NodeReference { const parseAsLiteralResult = safeParse(literal(), input); @@ -99,10 +102,29 @@ function flattenNodeObject_( return nodeRef; } -export default function flattenNodeObject(input: object): { +type FlattenNodeObjectReturnValue = { + /** A graph consists of one or more objects. */ readonly graph: readonly FlattenedNodeObject[]; + /** A node reference object of the input. */ readonly output: NodeReference; -} { +}; + +/** + * Flattens a node object into a graph of one or more objects. + * + * The output graph is JSON-LD compliant, however, it is not strictly flattened. + * + * Notes: + * + * - Does not completely strictly follow JSON-LD flattening strategy. + * - The result is parseable as JSON-LD, just not "perfectly flattened JSON-LD". + * - Does not support every syntax in JSON-LD, such as `@value`. + * - Node references are *not* flattened to string such as `"_:b1"`, instead, it will be kept as `{ "@id": "_:b1" }` + * + * @param input Boolean, number, null, string, or plain object with or without `@id`. + * @returns {FlattenNodeObjectReturnValue} A graph and a node reference. + */ +export default function flattenNodeObject(input: object): FlattenNodeObjectReturnValue { parse(object({}), input); const graph = new Map(); From 937118ae5e858c7390d7dd8d41c5575b19fa5663 Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 23:39:31 +0000 Subject: [PATCH 008/125] Add expandArray --- .../private/schemas/FlattenedNodeObject.ts | 4 +- .../src/private/schemas/expandArray.spec.ts | 32 +++++++++++ .../src/private/schemas/expandArray.ts | 57 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 packages/api-graph/src/private/schemas/expandArray.spec.ts create mode 100644 packages/api-graph/src/private/schemas/expandArray.ts diff --git a/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts b/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts index acba579031..c226c2de9e 100644 --- a/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts +++ b/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts @@ -27,9 +27,9 @@ function flattenedNodeObject>(message?: TMess return pipe( objectWithRest( { - '@context': optional(string()), + '@context': optional(string('Complex @context is not supported in our implementation')), '@id': identifier(), - '@type': optional(string()) + '@type': optional(union([array(string()), string()])) }, flattenedNodeObjectPropertyValue(), message diff --git a/packages/api-graph/src/private/schemas/expandArray.spec.ts b/packages/api-graph/src/private/schemas/expandArray.spec.ts new file mode 100644 index 0000000000..fd3a965edf --- /dev/null +++ b/packages/api-graph/src/private/schemas/expandArray.spec.ts @@ -0,0 +1,32 @@ +import { scenario } from '@testduet/given-when-then'; +import expandArray from './expandArray'; +import type { FlattenedNodeObject } from './FlattenedNodeObject'; + +scenario('expandArray', bdd => { + bdd + .given( + 'a JSON-LD object with @context, @id, and @type', + () => + ({ + '@context': 'http://schema.org/', + '@id': '_:b1', + '@type': 'Person', + name: 'Jane Doe', + jobTitle: 'Professor', + telephone: '(425) 123-4567', + url: 'http://www.janedoe.com' + }) satisfies FlattenedNodeObject + ) + .when('expanded', value => expandArray(value)) + .then('should wrap @type and property values in array', (_, actual) => { + expect(actual).toEqual({ + '@context': 'http://schema.org/', + '@id': '_:b1', + '@type': ['Person'], + name: ['Jane Doe'], + jobTitle: ['Professor'], + telephone: ['(425) 123-4567'], + url: ['http://www.janedoe.com'] + }); + }); +}); diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts new file mode 100644 index 0000000000..3dcb44bd69 --- /dev/null +++ b/packages/api-graph/src/private/schemas/expandArray.ts @@ -0,0 +1,57 @@ +import { array, objectWithRest, optional, parse, pipe, readonly, string, union, type InferOutput } from 'valibot'; + +import type { FlattenedNodeObject } from './FlattenedNodeObject'; +import identifier from './Identifier'; +import { literal, type Literal } from './Literal'; +import { nodeReference, type NodeReference } from './NodeReference'; + +const expandedFlatNodeObjectSchema = pipe( + objectWithRest( + { + '@context': optional(string()), + '@id': optional(identifier()), + '@type': optional(array(string())) + }, + array(union([literal(), nodeReference()])) + ), + readonly() +); + +type ExpandedFlatNodeObject = InferOutput; + +function expandArray(node: FlattenedNodeObject): ExpandedFlatNodeObject { + const propertyMap = new Map(); + let context: string | undefined; + let id: string | undefined; + + for (const [key, value] of Object.entries(node)) { + const parsedValue = parse(union([array(nodeReference()), array(literal()), nodeReference(), literal()]), value); + + switch (key) { + case '@context': + context = parse(string(), value); + break; + + case '@id': + id = parse(string(), value); + break; + + default: + // TODO: [P*] Test mixed array with literal and node reference. + propertyMap.set(key, Array.isArray(parsedValue) ? parsedValue : Object.freeze([parsedValue])); + break; + } + } + + return parse( + expandedFlatNodeObjectSchema, + Object.fromEntries([ + ...(context ? [['@context', context]] : []), + ...(id ? [['@id', id]] : []), + ...Array.from(propertyMap) + ]) + ); +} + +export default expandArray; +export { expandedFlatNodeObjectSchema, type ExpandedFlatNodeObject }; From f82e8ac0126b22bc2838a30f88a22cd3bdd9d38d Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 23:53:46 +0000 Subject: [PATCH 009/125] Add doc --- .../src/private/schemas/expandArray.spec.ts | 59 +++++++++++++++++++ .../src/private/schemas/expandArray.ts | 11 ++++ .../src/private/schemas/flattenNodeObject.ts | 1 + 3 files changed, 71 insertions(+) diff --git a/packages/api-graph/src/private/schemas/expandArray.spec.ts b/packages/api-graph/src/private/schemas/expandArray.spec.ts index fd3a965edf..cdd685d0d1 100644 --- a/packages/api-graph/src/private/schemas/expandArray.spec.ts +++ b/packages/api-graph/src/private/schemas/expandArray.spec.ts @@ -1,4 +1,5 @@ import { scenario } from '@testduet/given-when-then'; + import expandArray from './expandArray'; import type { FlattenedNodeObject } from './FlattenedNodeObject'; @@ -29,4 +30,62 @@ scenario('expandArray', bdd => { url: ['http://www.janedoe.com'] }); }); + + bdd + .given( + 'a JSON-LD object without @context, @id, and @type', + () => + ({ + '@id': '_:b1', + name: 'Jane Doe', + jobTitle: 'Professor', + telephone: '(425) 123-4567', + url: 'http://www.janedoe.com' + }) satisfies FlattenedNodeObject + ) + .when('expanded', value => expandArray(value)) + .then('should wrap @type and property values in array', (_, actual) => { + expect(actual).toEqual({ + '@id': '_:b1', + name: ['Jane Doe'], + jobTitle: ['Professor'], + telephone: ['(425) 123-4567'], + url: ['http://www.janedoe.com'] + }); + }); + + bdd + .given( + 'a recipe JSON-LD object with some property values of array', + () => + ({ + '@id': '_:b1', + name: 'Mojito', + ingredient: [ + '12 fresh mint leaves', + '1/2 lime, juiced with pulp', + '1 tablespoons white sugar', + '1 cup ice cubes', + '2 fluid ounces white rum', + '1/2 cup club soda' + ], + yield: '1 cocktail' + }) satisfies FlattenedNodeObject + ) + .when('expanded', value => expandArray(value)) + .then('should wrap property values in array', (_, actual) => { + expect(actual).toEqual({ + '@id': '_:b1', + name: ['Mojito'], + ingredient: [ + '12 fresh mint leaves', + '1/2 lime, juiced with pulp', + '1 tablespoons white sugar', + '1 cup ice cubes', + '2 fluid ounces white rum', + '1/2 cup club soda' + ], + yield: ['1 cocktail'] + }); + }); }); diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts index 3dcb44bd69..2c6e2fe7fc 100644 --- a/packages/api-graph/src/private/schemas/expandArray.ts +++ b/packages/api-graph/src/private/schemas/expandArray.ts @@ -19,6 +19,17 @@ const expandedFlatNodeObjectSchema = pipe( type ExpandedFlatNodeObject = InferOutput; +/** + * Expands `@type` and all property values of a flat node object into array. + * + * Notes: + * + * - `@context` and `@id` are not expanded, they will be kept as string. + * - `@type` is expanded as array of string. + * + * @param node + * @returns + */ function expandArray(node: FlattenedNodeObject): ExpandedFlatNodeObject { const propertyMap = new Map(); let context: string | undefined; diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts index 25bd141a74..99ddb128b8 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -116,6 +116,7 @@ type FlattenNodeObjectReturnValue = { * * Notes: * + * - All nodes has `@id` and are linked without orphans. * - Does not completely strictly follow JSON-LD flattening strategy. * - The result is parseable as JSON-LD, just not "perfectly flattened JSON-LD". * - Does not support every syntax in JSON-LD, such as `@value`. From 7b39c488dd3c0db912e17d901f6bd2e47e8f9fbd Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 27 Oct 2025 23:58:18 +0000 Subject: [PATCH 010/125] Add test --- .../src/private/schemas/expandArray.spec.ts | 14 ++++++++++++++ .../api-graph/src/private/schemas/expandArray.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/api-graph/src/private/schemas/expandArray.spec.ts b/packages/api-graph/src/private/schemas/expandArray.spec.ts index cdd685d0d1..c4c80251dc 100644 --- a/packages/api-graph/src/private/schemas/expandArray.spec.ts +++ b/packages/api-graph/src/private/schemas/expandArray.spec.ts @@ -88,4 +88,18 @@ scenario('expandArray', bdd => { yield: ['1 cocktail'] }); }); + + bdd + .given( + 'a JSON-LD object with @type of type array', + () => + ({ + '@id': '_:b1', + '@type': ['HowTo', 'Message'] + }) satisfies FlattenedNodeObject + ) + .when('expnaded', value => expandArray(value)) + .then('should return both types', (_, actual) => { + expect(actual['@type']).toEqual(['HowTo', 'Message']); + }); }); diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts index 2c6e2fe7fc..57febed140 100644 --- a/packages/api-graph/src/private/schemas/expandArray.ts +++ b/packages/api-graph/src/private/schemas/expandArray.ts @@ -28,7 +28,7 @@ type ExpandedFlatNodeObject = InferOutput; * - `@type` is expanded as array of string. * * @param node - * @returns + * @returns A node object with property values expanded. */ function expandArray(node: FlattenedNodeObject): ExpandedFlatNodeObject { const propertyMap = new Map(); From 0f2fa870ee43fe708eae55bcaddb2df19165d112 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 28 Oct 2025 00:16:09 +0000 Subject: [PATCH 011/125] Add isOfType --- .../src/private/schemas/isOfType.spec.ts | 33 +++++++++++++++++++ .../api-graph/src/private/schemas/isOfType.ts | 7 ++++ 2 files changed, 40 insertions(+) create mode 100644 packages/api-graph/src/private/schemas/isOfType.spec.ts create mode 100644 packages/api-graph/src/private/schemas/isOfType.ts diff --git a/packages/api-graph/src/private/schemas/isOfType.spec.ts b/packages/api-graph/src/private/schemas/isOfType.spec.ts new file mode 100644 index 0000000000..f1004d90a0 --- /dev/null +++ b/packages/api-graph/src/private/schemas/isOfType.spec.ts @@ -0,0 +1,33 @@ +import { scenario } from '@testduet/given-when-then'; +import type { FlattenedNodeObject } from './FlattenedNodeObject'; +import isOfType from './isOfType'; + +scenario('isOfType', bdd => { + bdd + .given('@type of string', () => ({ '@id': '_:b1', '@type': 'Message' }) satisfies FlattenedNodeObject) + .when.oneOf([ + ['isOfType() is called with "Message"', value => [isOfType(value, 'Message'), true]], + ['isOfType() is called with "HowTo"', value => [isOfType(value, 'HowTo'), false]] + ]) + .then('should return expected value', (_, [actual, expected]) => { + expect(actual).toBe(expected); + }); + + bdd + .given('@type of string', () => ({ '@id': '_:b1', '@type': ['HowTo', 'Message'] }) satisfies FlattenedNodeObject) + .when.oneOf([ + ['isOfType() is called with "Message"', value => [isOfType(value, 'Message'), true]], + ['isOfType() is called with "HowTo"', value => [isOfType(value, 'HowTo'), true]], + ['isOfType() is called with "Person"', value => [isOfType(value, 'Person'), false]] + ]) + .then('should return expected value', (_, [actual, expected]) => { + expect(actual).toBe(expected); + }); + + bdd + .given('object without @type', () => ({ '@id': '_:b1' }) satisfies FlattenedNodeObject) + .when('isOfType() is called with "Message"', value => [isOfType(value, 'Message'), false]) + .then('should return expected false', (_, [actual, expected]) => { + expect(actual).toBe(expected); + }); +}); diff --git a/packages/api-graph/src/private/schemas/isOfType.ts b/packages/api-graph/src/private/schemas/isOfType.ts new file mode 100644 index 0000000000..75a7032b1f --- /dev/null +++ b/packages/api-graph/src/private/schemas/isOfType.ts @@ -0,0 +1,7 @@ +import type { FlattenedNodeObject } from './FlattenedNodeObject'; + +export default function isOfType(nodeObject: FlattenedNodeObject, type: string): boolean { + const types = nodeObject['@type']; + + return typeof types === 'string' ? types === type : !!types && types.includes(type); +} From fab9225bdacde7e8a63cc8ad4e5916b014b10494 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 28 Oct 2025 07:33:40 +0000 Subject: [PATCH 012/125] Rename to FlatNodeObject --- ...attenedNodeObject.ts => FlatNodeObject.ts} | 16 +++++++------- .../src/private/schemas/expandArray.ts | 4 ++-- .../src/private/schemas/flattenNodeObject.ts | 21 +++++++++---------- .../src/private/schemas/isOfType.spec.ts | 8 +++---- .../api-graph/src/private/schemas/isOfType.ts | 4 ++-- 5 files changed, 25 insertions(+), 28 deletions(-) rename packages/api-graph/src/private/schemas/{FlattenedNodeObject.ts => FlatNodeObject.ts} (55%) diff --git a/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts b/packages/api-graph/src/private/schemas/FlatNodeObject.ts similarity index 55% rename from packages/api-graph/src/private/schemas/FlattenedNodeObject.ts rename to packages/api-graph/src/private/schemas/FlatNodeObject.ts index c226c2de9e..94abefd8d8 100644 --- a/packages/api-graph/src/private/schemas/FlattenedNodeObject.ts +++ b/packages/api-graph/src/private/schemas/FlatNodeObject.ts @@ -4,16 +4,14 @@ import { literal } from './Literal'; import { nodeReference } from './NodeReference'; import freeze from './private/freeze'; -// type FlattenedNodeObjectPropertyValue = Literal | NodeReference | readonly (Literal | NodeReference)[]; - -function flattenedNodeObjectPropertyValue>(message?: TMessage | undefined) { +function flatNodeObjectPropertyValue>(message?: TMessage | undefined) { return union( [pipe(array(union([literal(), nodeReference()])), freeze()), pipe(literal()), pipe(nodeReference())], message ); } -type FlattenedNodeObjectPropertyValue = InferOutput>; +type FlatNodeObjectPropertyValue = InferOutput>; /** * Schema of JSON-LD node object. @@ -23,7 +21,7 @@ type FlattenedNodeObjectPropertyValue = InferOutput>(message?: TMessage | undefined) { +function flatNodeObject>(message?: TMessage | undefined) { return pipe( objectWithRest( { @@ -31,14 +29,14 @@ function flattenedNodeObject>(message?: TMess '@id': identifier(), '@type': optional(union([array(string()), string()])) }, - flattenedNodeObjectPropertyValue(), + flatNodeObjectPropertyValue(), message ), freeze() ); } -type FlattenedNodeObject = InferOutput>; +type FlatNodeObject = InferOutput>; -export default flattenedNodeObject; -export { flattenedNodeObjectPropertyValue, type FlattenedNodeObject, type FlattenedNodeObjectPropertyValue }; +export default flatNodeObject; +export { flatNodeObjectPropertyValue, type FlatNodeObject, type FlatNodeObjectPropertyValue }; diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts index 57febed140..c508458bd3 100644 --- a/packages/api-graph/src/private/schemas/expandArray.ts +++ b/packages/api-graph/src/private/schemas/expandArray.ts @@ -1,6 +1,6 @@ import { array, objectWithRest, optional, parse, pipe, readonly, string, union, type InferOutput } from 'valibot'; -import type { FlattenedNodeObject } from './FlattenedNodeObject'; +import type { FlatNodeObject } from './FlatNodeObject'; import identifier from './Identifier'; import { literal, type Literal } from './Literal'; import { nodeReference, type NodeReference } from './NodeReference'; @@ -30,7 +30,7 @@ type ExpandedFlatNodeObject = InferOutput; * @param node * @returns A node object with property values expanded. */ -function expandArray(node: FlattenedNodeObject): ExpandedFlatNodeObject { +function expandArray(node: FlatNodeObject): ExpandedFlatNodeObject { const propertyMap = new Map(); let context: string | undefined; let id: string | undefined; diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts index 99ddb128b8..0587812e8e 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -1,7 +1,6 @@ import { object, optional, parse, safeParse } from 'valibot'; -import type { FlattenedNodeObject, FlattenedNodeObjectPropertyValue } from './FlattenedNodeObject'; -import flattenedNodeObject from './FlattenedNodeObject'; +import flatNodeObject, { type FlatNodeObject, type FlatNodeObjectPropertyValue } from './FlatNodeObject'; import identifier from './Identifier'; import { literal, type Literal } from './Literal'; import { nodeReference, type NodeReference } from './NodeReference'; @@ -10,25 +9,25 @@ type FlattenNodeObjectInput = Literal | (object & { '@id'?: string }); function flattenNodeObject_( input: T, - graphMap: Map, + graphMap: Map, refMap: Map ): T; function flattenNodeObject_( input: FlattenNodeObjectInput, - graphMap: Map, + graphMap: Map, refMap: Map ): NodeReference; function flattenNodeObject_( input: FlattenNodeObjectInput | Literal, - graphMap: Map, + graphMap: Map, refMap: Map ): Literal | NodeReference; function flattenNodeObject_( input: FlattenNodeObjectInput | Literal, - graphMap: Map, + graphMap: Map, refMap: Map ): Literal | NodeReference { const parseAsLiteralResult = safeParse(literal(), input); @@ -69,10 +68,10 @@ function flattenNodeObject_( // @ts-expect-error graphMap.set(id, undefined); - const targetMap = new Map(); + const targetMap = new Map(); for (const [key, value] of Object.entries(input)) { - let parsedValue: FlattenedNodeObjectPropertyValue; + let parsedValue: FlatNodeObjectPropertyValue; if (Array.isArray(value)) { const resultArray: (Literal | NodeReference)[] = []; @@ -93,7 +92,7 @@ function flattenNodeObject_( targetMap.set('@id', id); - const output: FlattenedNodeObject = parse(flattenedNodeObject(), Object.fromEntries(Array.from(targetMap))); + const output: FlatNodeObject = parse(flatNodeObject(), Object.fromEntries(Array.from(targetMap))); const nodeRef = parse(nodeReference(), Object.freeze({ '@id': id })); graphMap.set(id, output); @@ -104,7 +103,7 @@ function flattenNodeObject_( type FlattenNodeObjectReturnValue = { /** A graph consists of one or more objects. */ - readonly graph: readonly FlattenedNodeObject[]; + readonly graph: readonly FlatNodeObject[]; /** A node reference object of the input. */ readonly output: NodeReference; }; @@ -128,7 +127,7 @@ type FlattenNodeObjectReturnValue = { export default function flattenNodeObject(input: object): FlattenNodeObjectReturnValue { parse(object({}), input); - const graph = new Map(); + const graph = new Map(); const refMap = new Map(); const output = flattenNodeObject_(input, graph, refMap); diff --git a/packages/api-graph/src/private/schemas/isOfType.spec.ts b/packages/api-graph/src/private/schemas/isOfType.spec.ts index f1004d90a0..dddfb814f6 100644 --- a/packages/api-graph/src/private/schemas/isOfType.spec.ts +++ b/packages/api-graph/src/private/schemas/isOfType.spec.ts @@ -1,10 +1,10 @@ import { scenario } from '@testduet/given-when-then'; -import type { FlattenedNodeObject } from './FlattenedNodeObject'; +import type { FlatNodeObject } from './FlatNodeObject'; import isOfType from './isOfType'; scenario('isOfType', bdd => { bdd - .given('@type of string', () => ({ '@id': '_:b1', '@type': 'Message' }) satisfies FlattenedNodeObject) + .given('@type of string', () => ({ '@id': '_:b1', '@type': 'Message' }) satisfies FlatNodeObject) .when.oneOf([ ['isOfType() is called with "Message"', value => [isOfType(value, 'Message'), true]], ['isOfType() is called with "HowTo"', value => [isOfType(value, 'HowTo'), false]] @@ -14,7 +14,7 @@ scenario('isOfType', bdd => { }); bdd - .given('@type of string', () => ({ '@id': '_:b1', '@type': ['HowTo', 'Message'] }) satisfies FlattenedNodeObject) + .given('@type of string', () => ({ '@id': '_:b1', '@type': ['HowTo', 'Message'] }) satisfies FlatNodeObject) .when.oneOf([ ['isOfType() is called with "Message"', value => [isOfType(value, 'Message'), true]], ['isOfType() is called with "HowTo"', value => [isOfType(value, 'HowTo'), true]], @@ -25,7 +25,7 @@ scenario('isOfType', bdd => { }); bdd - .given('object without @type', () => ({ '@id': '_:b1' }) satisfies FlattenedNodeObject) + .given('object without @type', () => ({ '@id': '_:b1' }) satisfies FlatNodeObject) .when('isOfType() is called with "Message"', value => [isOfType(value, 'Message'), false]) .then('should return expected false', (_, [actual, expected]) => { expect(actual).toBe(expected); diff --git a/packages/api-graph/src/private/schemas/isOfType.ts b/packages/api-graph/src/private/schemas/isOfType.ts index 75a7032b1f..0be813ff25 100644 --- a/packages/api-graph/src/private/schemas/isOfType.ts +++ b/packages/api-graph/src/private/schemas/isOfType.ts @@ -1,6 +1,6 @@ -import type { FlattenedNodeObject } from './FlattenedNodeObject'; +import type { FlatNodeObject } from './FlatNodeObject'; -export default function isOfType(nodeObject: FlattenedNodeObject, type: string): boolean { +export default function isOfType(nodeObject: FlatNodeObject, type: string): boolean { const types = nodeObject['@type']; return typeof types === 'string' ? types === type : !!types && types.includes(type); From f24dd7ba77277dd48cc9101e97d27507368bc3bc Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 28 Oct 2025 07:35:54 +0000 Subject: [PATCH 013/125] Comment --- packages/api-graph/src/private/schemas/NodeObject.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api-graph/src/private/schemas/NodeObject.ts b/packages/api-graph/src/private/schemas/NodeObject.ts index 1a46a10c96..841dbc9154 100644 --- a/packages/api-graph/src/private/schemas/NodeObject.ts +++ b/packages/api-graph/src/private/schemas/NodeObject.ts @@ -1,3 +1,4 @@ +// TODO: [P*] Maybe not used. import { array, lazy, From 14b0e3427813c8c50d37ee344b391f2e7a62b003 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 28 Oct 2025 07:35:59 +0000 Subject: [PATCH 014/125] Use flatNodeObject --- .../api-graph/src/private/schemas/flattenNodeObject.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts index ff33567f54..adc51d944c 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -4,10 +4,10 @@ import { array, assert, length, object, pipe } from 'valibot'; import blankNodeIdentifier from './BlankNodeIdentifier'; import './expectExtendValibot'; +import flatNodeObject from './FlatNodeObject'; import flattenNodeObject from './flattenNodeObject'; import identifier from './Identifier'; import type { Literal } from './Literal'; -import { nodeObject } from './NodeObject'; import { nodeReference } from './NodeReference'; beforeEach(() => { @@ -46,7 +46,7 @@ scenario('flattenNodeObject()', bdd => { assert(blankNodeIdentifier(), output['@id']); }) .and('should return a graph with one node object', (_, { graph }) => { - assert(pipe(array(nodeObject()), length(1)), graph); + assert(pipe(array(flatNodeObject()), length(1)), graph); }) .and('should return a graph with the node object', ([_, expected], { graph, output }) => { expect(graph).toEqual([{ ...expected, '@id': output['@id'] }]); @@ -102,7 +102,7 @@ scenario('flattenNodeObject()', bdd => { const geoObject = graph.find(object => object !== rootObject); - assert(nodeObject(), geoObject); + assert(flatNodeObject(), geoObject); expect(rootObject.geo['@id']).toBe(geoObject['@id']); }); From 66e7cb02e81e9fb490f189f21eb2b83f9cbeb8aa Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 28 Oct 2025 07:59:25 +0000 Subject: [PATCH 015/125] Add GraphProvider.mergeNode --- .../api-graph/src/private/GraphContext.ts | 18 ++++++---- .../api-graph/src/private/GraphProvider.tsx | 35 ++++++++++++++++--- .../src/private/schemas/FlatNodeObject.ts | 1 + .../src/private/schemas/expandArray.ts | 2 +- .../src/private/schemas/flattenNodeObject.ts | 5 ++- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/api-graph/src/private/GraphContext.ts b/packages/api-graph/src/private/GraphContext.ts index fa57fd662b..9a0f8b3999 100644 --- a/packages/api-graph/src/private/GraphContext.ts +++ b/packages/api-graph/src/private/GraphContext.ts @@ -1,20 +1,26 @@ import { createContext, useContext } from 'react'; -import { map, object, pipe, readonly, string, type InferOutput } from 'valibot'; +import { custom, function_, map, object, pipe, readonly, safeParse, string, type InferOutput } from 'valibot'; -import { nodeObject } from './schemas/NodeObject'; +import { expandedFlatNodeObjectSchema } from './schemas/expandArray'; +import type { FlattenNodeObjectInput } from './schemas/flattenNodeObject'; const graphContextSchema = pipe( object({ - graph: pipe(map(string(), nodeObject()), readonly()) + graph: pipe(map(string(), expandedFlatNodeObjectSchema), readonly()), + mergeNode: custom<(node: FlattenNodeObjectInput) => void>(value => safeParse(function_(), value).success) }), readonly() ); type GraphContextType = InferOutput; -const GraphContext = createContext({ - graph: Object.freeze(new Map()) -}); +const GraphContext = createContext( + new Proxy({} as GraphContextType, { + get() { + throw new Error('This hook can only be used under '); + } + }) +); function useGraphContext(): GraphContextType { return useContext(GraphContext); diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx index 939c24746a..3f5bec3bc4 100644 --- a/packages/api-graph/src/private/GraphProvider.tsx +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -1,9 +1,11 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import GraphContext from './GraphContext'; -import type { NodeObject } from './schemas/NodeObject'; +import type { ExpandedFlatNodeObject } from './schemas/expandArray'; +import expandArray from './schemas/expandArray'; +import flattenNodeObject, { type FlattenNodeObjectInput } from './schemas/flattenNodeObject'; const graphProviderPropsSchema = pipe( object({ @@ -17,9 +19,34 @@ type GraphProviderProps = InferInput; function GraphProvider(props: GraphProviderProps) { const { children } = validateProps(graphProviderPropsSchema, props); - const [graph, setGraph] = useState>(() => Object.freeze(new Map())); + const [graph, setGraph] = useState>(() => Object.freeze(new Map())); - const context = useMemo(() => Object.freeze({ graph }), [graph]); + const mergeGraph = useCallback( + (graph: ExpandedFlatNodeObject[]) => { + setGraph(existingGraph => { + const nextGraph = new Map(existingGraph); + + for (const node of graph) { + nextGraph.set(node['@id'], node); + } + + return nextGraph; + }); + }, + [setGraph] + ); + + const mergeNode = useCallback( + (node: FlattenNodeObjectInput) => { + const { graph } = flattenNodeObject(node); + const expandedGraph = graph.map(expandArray); + + mergeGraph(expandedGraph); + }, + [mergeGraph] + ); + + const context = useMemo(() => Object.freeze({ graph, mergeNode }), [graph, mergeNode]); return {children}; } diff --git a/packages/api-graph/src/private/schemas/FlatNodeObject.ts b/packages/api-graph/src/private/schemas/FlatNodeObject.ts index 94abefd8d8..d06e9af655 100644 --- a/packages/api-graph/src/private/schemas/FlatNodeObject.ts +++ b/packages/api-graph/src/private/schemas/FlatNodeObject.ts @@ -1,4 +1,5 @@ import { array, objectWithRest, optional, pipe, string, union, type ErrorMessage, type InferOutput } from 'valibot'; + import identifier from './Identifier'; import { literal } from './Literal'; import { nodeReference } from './NodeReference'; diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts index c508458bd3..cbd66898bc 100644 --- a/packages/api-graph/src/private/schemas/expandArray.ts +++ b/packages/api-graph/src/private/schemas/expandArray.ts @@ -9,7 +9,7 @@ const expandedFlatNodeObjectSchema = pipe( objectWithRest( { '@context': optional(string()), - '@id': optional(identifier()), + '@id': identifier(), '@type': optional(array(string())) }, array(union([literal(), nodeReference()])) diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts index 0587812e8e..8c9e30dc6b 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -124,7 +124,7 @@ type FlattenNodeObjectReturnValue = { * @param input Boolean, number, null, string, or plain object with or without `@id`. * @returns {FlattenNodeObjectReturnValue} A graph and a node reference. */ -export default function flattenNodeObject(input: object): FlattenNodeObjectReturnValue { +function flattenNodeObject(input: FlattenNodeObjectInput): FlattenNodeObjectReturnValue { parse(object({}), input); const graph = new Map(); @@ -133,3 +133,6 @@ export default function flattenNodeObject(input: object): FlattenNodeObjectRetur return { graph: Object.freeze(Array.from(graph.values())), output }; } + +export default flattenNodeObject; +export { type FlattenNodeObjectInput, type FlattenNodeObjectReturnValue }; From 7857c1caebdf8bd909840d8a2cb99893a89df422 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 01:33:08 +0000 Subject: [PATCH 016/125] Add graph --- .../src/private/Graph.hasPart.spec.ts | 60 ++++++++ packages/api-graph/src/private/Graph.spec.ts | 24 +++ packages/api-graph/src/private/Graph.ts | 144 ++++++++++++++++++ .../api-graph/src/private/GraphProvider.tsx | 1 + .../src/private/schemas/expandArray.spec.ts | 10 +- .../src/private/schemas/expandArray.ts | 22 ++- 6 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 packages/api-graph/src/private/Graph.hasPart.spec.ts create mode 100644 packages/api-graph/src/private/Graph.spec.ts create mode 100644 packages/api-graph/src/private/Graph.ts diff --git a/packages/api-graph/src/private/Graph.hasPart.spec.ts b/packages/api-graph/src/private/Graph.hasPart.spec.ts new file mode 100644 index 0000000000..564772665e --- /dev/null +++ b/packages/api-graph/src/private/Graph.hasPart.spec.ts @@ -0,0 +1,60 @@ +import { scenario } from '@testduet/given-when-then'; +import Graph from './Graph'; +import type { ExpandedFlatNodeObject } from './schemas/expandArray'; + +scenario('Graph class', bdd => { + bdd + .given('a graph with a Conversation node', () => { + const graph = new Graph(); + + graph.upsert({ '@id': '_:c1', '@type': ['Conversation'] } satisfies ExpandedFlatNodeObject); + + return { graph }; + }) + .and('an observer', condition => ({ ...condition, iterator: condition.graph.observe() })) + .when('a Message node is upserted', ({ graph }) => { + graph.upsert({ + '@id': '_:m1', + '@type': ['Message'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Hello, World!'] + } satisfies ExpandedFlatNodeObject); + }) + .then('the graph should have the node', ({ graph }) => { + expect(Array.from(graph.snapshot().entries())).toEqual([ + ['_:c1', { '@id': '_:c1', '@type': ['Conversation'], hasPart: [{ '@id': '_:m1' }] }], + ['_:m1', { '@id': '_:m1', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Hello, World!'] }] + ]); + }) + .when('two more Message nodes are upserted', ({ graph }) => { + graph.upsert( + { + '@id': '_:m2', + '@type': ['Message'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Aloha!'] + }, + { + '@id': '_:m3', + '@type': ['Message'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Good morning!'] + } + ); + }) + .then('the graph should have the node', ({ graph }) => { + expect(Array.from(graph.snapshot().entries())).toEqual([ + [ + '_:c1', + { + '@id': '_:c1', + '@type': ['Conversation'], + hasPart: [{ '@id': '_:m1' }, { '@id': '_:m2' }, { '@id': '_:m3' }] + } + ], + ['_:m1', { '@id': '_:m1', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Hello, World!'] }], + ['_:m2', { '@id': '_:m2', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Aloha!'] }], + ['_:m3', { '@id': '_:m3', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Good morning!'] }] + ]); + }); +}); diff --git a/packages/api-graph/src/private/Graph.spec.ts b/packages/api-graph/src/private/Graph.spec.ts new file mode 100644 index 0000000000..5d790340eb --- /dev/null +++ b/packages/api-graph/src/private/Graph.spec.ts @@ -0,0 +1,24 @@ +import { scenario } from '@testduet/given-when-then'; +import Graph from './Graph'; +import type { ExpandedFlatNodeObject } from './schemas/expandArray'; + +scenario('Graph class', bdd => { + bdd + .given('a graph', () => ({ graph: new Graph() })) + .and('a node', ({ graph }) => ({ + graph, + node: { '@id': '_:b1', value: ['abc'] } satisfies ExpandedFlatNodeObject + })) + .and('an observer', condition => ({ ...condition, iterator: condition.graph.observe() })) + .when('upserted', ({ graph, node }) => { + graph.upsert(node); + }) + .then('the graph should have the node', ({ graph }) => { + expect(Array.from(graph.snapshot().entries())).toEqual([['_:b1', { '@id': '_:b1', value: ['abc'] }]]); + }) + .and('observer', async ({ iterator }) => { + const result = await iterator.next(); + + expect(result).toEqual({ done: false, value: [{ '@id': '_:b1', value: ['abc'] }] }); + }); +}); diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts new file mode 100644 index 0000000000..7f34c196fd --- /dev/null +++ b/packages/api-graph/src/private/Graph.ts @@ -0,0 +1,144 @@ +import { array, object, parse, pipe, safeParse, type InferOutput } from 'valibot'; + +import { expandedFlatNodeObjectSchema, type ExpandedFlatNodeObject } from './schemas/expandArray'; +import type { Identifier } from './schemas/Identifier'; +import identifier from './schemas/Identifier'; +import { nodeReference } from './schemas/NodeReference'; +import freeze from './schemas/private/freeze'; + +type ObserverChange = readonly ExpandedFlatNodeObject[]; + +const childNodeSchema = pipe(object({ isPartOf: pipe(array(nodeReference()), freeze()) }), freeze()); + +type ChildNode = InferOutput; + +function isChild(node: unknown): node is ChildNode { + return safeParse(childNodeSchema, node).success; +} + +const parentNodeSchema = pipe(object({ hasPart: pipe(array(nodeReference()), freeze()) }), freeze()); +type ParentNode = InferOutput; + +function isParent(node: unknown): node is ParentNode { + return safeParse(parentNodeSchema, node).success; +} + +class Graph extends EventTarget { + constructor() { + super(); + + this.#graph = new Map(); + this.#observerControllerSet = new Set(); + } + + #graph: Map; + #observerControllerSet: Set>; + + observe(): AsyncIterator { + const observerControllerSet = this.#observerControllerSet; + let thisController: ReadableStreamDefaultController; + + const stream = new ReadableStream({ + cancel() { + // Iterator.cancel() will call cancel(). + observerControllerSet.delete(thisController); + }, + start(controller) { + thisController = controller; + observerControllerSet.add(controller); + } + }); + + return stream.values(); + } + + snapshot() { + return new Map(this.#graph); + } + + upsert(...nodes: readonly ExpandedFlatNodeObject[]) { + const affectedNodeIds: Set = new Set(); + + for (const node of nodes) { + this.#graph.set(parse(identifier(), node['@id']), node); + + affectedNodeIds.add(node['@id'] as Identifier); + } + + for (const node of nodes) { + if (isParent(node)) { + const parentId = node['@id'] as Identifier; + + for (const { '@id': childId } of node.hasPart) { + const child = this.#graph.get(childId); + + if (!child) { + throw new Error(`Cannot find child with @id of ${childId}`); + } else { + const parentIds: Set = new Set(isChild(child) ? child.isPartOf.map(ref => ref['@id']) : []); + + if (!parentIds.has(parentId)) { + parentIds.add(parentId); + + this.#graph.set( + childId, + parse(expandedFlatNodeObjectSchema, { + ...child, + isPartOf: Array.from(parentIds.values()).map(id => ({ '@id': id })) + }) + ); + + affectedNodeIds.add(child['@id'] as Identifier); + } + } + } + } + + if (isChild(node)) { + const childId = node['@id'] as Identifier; + + for (const { '@id': parentId } of node.isPartOf) { + const parent = this.#graph.get(parentId); + + if (!parent) { + throw new Error(`Cannot find parent with @id of ${parentId}`); + } else { + const parentIds: Set = new Set(isParent(parent) ? parent.hasPart.map(ref => ref['@id']) : []); + + if (!parentIds.has(childId)) { + parentIds.add(childId); + + this.#graph.set( + parentId, + parse(expandedFlatNodeObjectSchema, { + ...parent, + hasPart: Array.from(parentIds.values()).map(id => ({ '@id': id })) + }) + ); + + affectedNodeIds.add(parent['@id'] as Identifier); + } + } + } + } + } + + const affectedNodes = Object.freeze( + Array.from(affectedNodeIds.values()).map(id => { + const node = this.#graph.get(id); + + if (!node) { + throw new Error(`ASSERTION: Cannot find affected node with @id of "${id}"`); + } + + return node; + }) + ); + + for (const controller of this.#observerControllerSet) { + controller.enqueue(affectedNodes); + } + } +} + +export default Graph; diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx index 3f5bec3bc4..4a5bfad9d5 100644 --- a/packages/api-graph/src/private/GraphProvider.tsx +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -46,6 +46,7 @@ function GraphProvider(props: GraphProviderProps) { [mergeGraph] ); + // Build graph using `useSyncExternalStore()` so we can run graph everywhere. const context = useMemo(() => Object.freeze({ graph, mergeNode }), [graph, mergeNode]); return {children}; diff --git a/packages/api-graph/src/private/schemas/expandArray.spec.ts b/packages/api-graph/src/private/schemas/expandArray.spec.ts index c4c80251dc..0c829ce788 100644 --- a/packages/api-graph/src/private/schemas/expandArray.spec.ts +++ b/packages/api-graph/src/private/schemas/expandArray.spec.ts @@ -1,7 +1,7 @@ import { scenario } from '@testduet/given-when-then'; import expandArray from './expandArray'; -import type { FlattenedNodeObject } from './FlattenedNodeObject'; +import type { FlatNodeObject } from './FlatNodeObject'; scenario('expandArray', bdd => { bdd @@ -16,7 +16,7 @@ scenario('expandArray', bdd => { jobTitle: 'Professor', telephone: '(425) 123-4567', url: 'http://www.janedoe.com' - }) satisfies FlattenedNodeObject + }) satisfies FlatNodeObject ) .when('expanded', value => expandArray(value)) .then('should wrap @type and property values in array', (_, actual) => { @@ -41,7 +41,7 @@ scenario('expandArray', bdd => { jobTitle: 'Professor', telephone: '(425) 123-4567', url: 'http://www.janedoe.com' - }) satisfies FlattenedNodeObject + }) satisfies FlatNodeObject ) .when('expanded', value => expandArray(value)) .then('should wrap @type and property values in array', (_, actual) => { @@ -70,7 +70,7 @@ scenario('expandArray', bdd => { '1/2 cup club soda' ], yield: '1 cocktail' - }) satisfies FlattenedNodeObject + }) satisfies FlatNodeObject ) .when('expanded', value => expandArray(value)) .then('should wrap property values in array', (_, actual) => { @@ -96,7 +96,7 @@ scenario('expandArray', bdd => { ({ '@id': '_:b1', '@type': ['HowTo', 'Message'] - }) satisfies FlattenedNodeObject + }) satisfies FlatNodeObject ) .when('expnaded', value => expandArray(value)) .then('should return both types', (_, actual) => { diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts index cbd66898bc..61e1f53272 100644 --- a/packages/api-graph/src/private/schemas/expandArray.ts +++ b/packages/api-graph/src/private/schemas/expandArray.ts @@ -1,9 +1,20 @@ -import { array, objectWithRest, optional, parse, pipe, readonly, string, union, type InferOutput } from 'valibot'; +import { + array, + objectWithRest, + optional, + parse, + pipe, + string, + union, + type InferOutput, + type ObjectSchema +} from 'valibot'; import type { FlatNodeObject } from './FlatNodeObject'; import identifier from './Identifier'; import { literal, type Literal } from './Literal'; import { nodeReference, type NodeReference } from './NodeReference'; +import freeze from './private/freeze'; const expandedFlatNodeObjectSchema = pipe( objectWithRest( @@ -14,10 +25,15 @@ const expandedFlatNodeObjectSchema = pipe( }, array(union([literal(), nodeReference()])) ), - readonly() + freeze() ); -type ExpandedFlatNodeObject = InferOutput; +// Due to limitation on TypeScript, we cannot truthfully represent the typing. +// We believe this is the most faithful we can get. +// The other option would be use `Symbol` for `@context`/`@id`/`@type`. +type ExpandedFlatNodeObject = + | Readonly>> + | { readonly [key: string]: InferOutput }; /** * Expands `@type` and all property values of a flat node object into array. From 7331b9691ed5958b7c8b6294db5ac5020367eb89 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 03:17:41 +0000 Subject: [PATCH 017/125] Add isPartOf auto-linking --- ...hasPart.spec.ts => Graph.isPartOf.spec.ts} | 24 ++- packages/api-graph/src/private/Graph.spec.ts | 7 +- packages/api-graph/src/private/Graph.ts | 151 ++++++++---------- .../api-graph/src/private/schemas/Literal.ts | 5 +- .../src/private/schemas/NodeObject.spec.ts | 50 +++--- .../src/private/schemas/NodeObject.ts | 132 +++++++-------- ...{expandArray.spec.ts => colorNode.spec.ts} | 45 +++++- .../src/private/schemas/colorNode.ts | 111 +++++++++++++ .../src/private/schemas/expandArray.ts | 84 ---------- .../flattenNodeObject.conversation.spec.ts | 2 +- .../private/schemas/flattenNodeObject.spec.ts | 6 +- 11 files changed, 337 insertions(+), 280 deletions(-) rename packages/api-graph/src/private/{Graph.hasPart.spec.ts => Graph.isPartOf.spec.ts} (75%) rename packages/api-graph/src/private/schemas/{expandArray.spec.ts => colorNode.spec.ts} (67%) create mode 100644 packages/api-graph/src/private/schemas/colorNode.ts delete mode 100644 packages/api-graph/src/private/schemas/expandArray.ts diff --git a/packages/api-graph/src/private/Graph.hasPart.spec.ts b/packages/api-graph/src/private/Graph.isPartOf.spec.ts similarity index 75% rename from packages/api-graph/src/private/Graph.hasPart.spec.ts rename to packages/api-graph/src/private/Graph.isPartOf.spec.ts index 564772665e..8537433ffc 100644 --- a/packages/api-graph/src/private/Graph.hasPart.spec.ts +++ b/packages/api-graph/src/private/Graph.isPartOf.spec.ts @@ -1,13 +1,14 @@ +import { expect } from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; import Graph from './Graph'; -import type { ExpandedFlatNodeObject } from './schemas/expandArray'; +import type { SlantNode } from './schemas/colorNode'; scenario('Graph class', bdd => { bdd .given('a graph with a Conversation node', () => { const graph = new Graph(); - graph.upsert({ '@id': '_:c1', '@type': ['Conversation'] } satisfies ExpandedFlatNodeObject); + graph.upsert({ '@id': '_:c1', '@type': ['Conversation'] } satisfies SlantNode); return { graph }; }) @@ -18,7 +19,7 @@ scenario('Graph class', bdd => { '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Hello, World!'] - } satisfies ExpandedFlatNodeObject); + } satisfies SlantNode); }) .then('the graph should have the node', ({ graph }) => { expect(Array.from(graph.snapshot().entries())).toEqual([ @@ -26,6 +27,14 @@ scenario('Graph class', bdd => { ['_:m1', { '@id': '_:m1', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Hello, World!'] }] ]); }) + .and('observer should receive both nodes', async ({ iterator }) => { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + ids: ['_:m1', '_:c1'] + } + }); + }) .when('two more Message nodes are upserted', ({ graph }) => { graph.upsert( { @@ -56,5 +65,14 @@ scenario('Graph class', bdd => { ['_:m2', { '@id': '_:m2', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Aloha!'] }], ['_:m3', { '@id': '_:m3', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Good morning!'] }] ]); + }) + .and('observer should receive all 3 nodes', async ({ iterator }) => { + await iterator.next(); // Skip the first change. + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + ids: ['_:m2', '_:m3', '_:c1'] + } + }); }); }); diff --git a/packages/api-graph/src/private/Graph.spec.ts b/packages/api-graph/src/private/Graph.spec.ts index 5d790340eb..707847a26f 100644 --- a/packages/api-graph/src/private/Graph.spec.ts +++ b/packages/api-graph/src/private/Graph.spec.ts @@ -1,13 +1,14 @@ +import { expect } from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; import Graph from './Graph'; -import type { ExpandedFlatNodeObject } from './schemas/expandArray'; +import type { SlantNode } from './schemas/colorNode'; scenario('Graph class', bdd => { bdd .given('a graph', () => ({ graph: new Graph() })) .and('a node', ({ graph }) => ({ graph, - node: { '@id': '_:b1', value: ['abc'] } satisfies ExpandedFlatNodeObject + node: { '@id': '_:b1', value: ['abc'] } satisfies SlantNode })) .and('an observer', condition => ({ ...condition, iterator: condition.graph.observe() })) .when('upserted', ({ graph, node }) => { @@ -19,6 +20,6 @@ scenario('Graph class', bdd => { .and('observer', async ({ iterator }) => { const result = await iterator.next(); - expect(result).toEqual({ done: false, value: [{ '@id': '_:b1', value: ['abc'] }] }); + expect(result).toEqual({ done: false, value: { ids: ['_:b1'] } }); }); }); diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 7f34c196fd..6fd3168e33 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -1,27 +1,17 @@ -import { array, object, parse, pipe, safeParse, type InferOutput } from 'valibot'; +/* eslint no-restricted-syntax: ["error", { + "selector": "CallExpression[callee.object.type='Identifier'][callee.object.name='Map'][callee.property.name='set']", + "message": "Map.set() is restricted, use this.#setGraphNode instead" +}, { + "selector": "CallExpression[callee.object.type='MemberExpression'][callee.property.name='set']", + "message": "Map.set() is restricted, use this.#setGraphNode instead" +}] */ -import { expandedFlatNodeObjectSchema, type ExpandedFlatNodeObject } from './schemas/expandArray'; -import type { Identifier } from './schemas/Identifier'; -import identifier from './schemas/Identifier'; -import { nodeReference } from './schemas/NodeReference'; -import freeze from './schemas/private/freeze'; - -type ObserverChange = readonly ExpandedFlatNodeObject[]; - -const childNodeSchema = pipe(object({ isPartOf: pipe(array(nodeReference()), freeze()) }), freeze()); - -type ChildNode = InferOutput; - -function isChild(node: unknown): node is ChildNode { - return safeParse(childNodeSchema, node).success; -} +import { parse } from 'valibot'; -const parentNodeSchema = pipe(object({ hasPart: pipe(array(nodeReference()), freeze()) }), freeze()); -type ParentNode = InferOutput; +import { slantNode, type SlantNode } from './schemas/colorNode'; +import type { Identifier } from './schemas/Identifier'; -function isParent(node: unknown): node is ParentNode { - return safeParse(parentNodeSchema, node).success; -} +type GraphChangeEvent = { readonly ids: readonly Identifier[] }; class Graph extends EventTarget { constructor() { @@ -31,14 +21,21 @@ class Graph extends EventTarget { this.#observerControllerSet = new Set(); } - #graph: Map; - #observerControllerSet: Set>; + #graph: Map; + #observerControllerSet: Set>; + + #setGraphNode(node: SlantNode) { + const safeNode = parse(slantNode(), node); - observe(): AsyncIterator { + // eslint-disable-next-line no-restricted-syntax + this.#graph.set(safeNode['@id'], safeNode); + } + + observe(): AsyncIterator { const observerControllerSet = this.#observerControllerSet; - let thisController: ReadableStreamDefaultController; + let thisController: ReadableStreamDefaultController; - const stream = new ReadableStream({ + const stream = new ReadableStream({ cancel() { // Iterator.cancel() will call cancel(). observerControllerSet.delete(thisController); @@ -56,87 +53,69 @@ class Graph extends EventTarget { return new Map(this.#graph); } - upsert(...nodes: readonly ExpandedFlatNodeObject[]) { - const affectedNodeIds: Set = new Set(); + upsert(...nodes: readonly SlantNode[]) { + const affectedIdSet: Set = new Set(); for (const node of nodes) { - this.#graph.set(parse(identifier(), node['@id']), node); + this.#setGraphNode(node); - affectedNodeIds.add(node['@id'] as Identifier); + affectedIdSet.add(node['@id']); } for (const node of nodes) { - if (isParent(node)) { - const parentId = node['@id'] as Identifier; - - for (const { '@id': childId } of node.hasPart) { - const child = this.#graph.get(childId); - - if (!child) { - throw new Error(`Cannot find child with @id of ${childId}`); - } else { - const parentIds: Set = new Set(isChild(child) ? child.isPartOf.map(ref => ref['@id']) : []); - - if (!parentIds.has(parentId)) { - parentIds.add(parentId); - - this.#graph.set( - childId, - parse(expandedFlatNodeObjectSchema, { - ...child, - isPartOf: Array.from(parentIds.values()).map(id => ({ '@id': id })) - }) - ); - - affectedNodeIds.add(child['@id'] as Identifier); - } - } + const nodeId = node['@id']; + + for (const { '@id': childId } of node.hasPart || []) { + const child = this.#graph.get(childId); + + if (!child) { + throw new Error(`Cannot find node denoted by node[@id="${nodeId}"].hasPart[@id="${childId}"]`, { + cause: { node } + }); + } + + const parentIds: Set = new Set(child.isPartOf?.map(ref => ref['@id']) || []); + + if (!parentIds.has(nodeId)) { + parentIds.add(nodeId); + + this.#setGraphNode({ ...child, isPartOf: Array.from(parentIds.values()).map(id => ({ '@id': id })) }); + + affectedIdSet.add(child['@id']); } } - if (isChild(node)) { - const childId = node['@id'] as Identifier; + for (const { '@id': parentId } of node.isPartOf || []) { + const parent = this.#graph.get(parentId); - for (const { '@id': parentId } of node.isPartOf) { - const parent = this.#graph.get(parentId); + if (!parent) { + throw new Error(`Cannot find node denoted by node[@id="${nodeId}"].isPartOf[@id="${parentId}"]`, { + cause: { node } + }); + } - if (!parent) { - throw new Error(`Cannot find parent with @id of ${parentId}`); - } else { - const parentIds: Set = new Set(isParent(parent) ? parent.hasPart.map(ref => ref['@id']) : []); + const parentIds: Set = new Set(parent.hasPart?.map(ref => ref['@id']) || []); - if (!parentIds.has(childId)) { - parentIds.add(childId); + if (!parentIds.has(nodeId)) { + parentIds.add(nodeId); - this.#graph.set( - parentId, - parse(expandedFlatNodeObjectSchema, { - ...parent, - hasPart: Array.from(parentIds.values()).map(id => ({ '@id': id })) - }) - ); + this.#setGraphNode({ ...parent, hasPart: Array.from(parentIds.values()).map(id => ({ '@id': id })) }); - affectedNodeIds.add(parent['@id'] as Identifier); - } - } + affectedIdSet.add(parent['@id']); } } } - const affectedNodes = Object.freeze( - Array.from(affectedNodeIds.values()).map(id => { - const node = this.#graph.get(id); - - if (!node) { - throw new Error(`ASSERTION: Cannot find affected node with @id of "${id}"`); - } + const affectedIds = Object.freeze(Array.from(affectedIdSet.values())); - return node; - }) - ); + for (const id of affectedIds) { + if (!this.#graph.has(id)) { + throw new Error(`ASSERTION: Cannot find affected node with @id of "${id}"`); + } + } for (const controller of this.#observerControllerSet) { - controller.enqueue(affectedNodes); + controller.enqueue(Object.freeze({ ids: affectedIds })); } } } diff --git a/packages/api-graph/src/private/schemas/Literal.ts b/packages/api-graph/src/private/schemas/Literal.ts index 96e54239ff..841b6e171a 100644 --- a/packages/api-graph/src/private/schemas/Literal.ts +++ b/packages/api-graph/src/private/schemas/Literal.ts @@ -1,4 +1,4 @@ -import { boolean, null_, number, safeParse, string, union, type ErrorMessage, type InferOutput } from 'valibot'; +import { boolean, number, safeParse, string, union, type ErrorMessage, type InferOutput } from 'valibot'; /** * Schema of JSON-LD literals. @@ -7,7 +7,8 @@ import { boolean, null_, number, safeParse, string, union, type ErrorMessage, ty * @returns */ function literal>(message?: TMessage | undefined) { - return union([boolean(), null_(), number(), string()], message); + // TODO: [P*] Null should not be literal because it could become null[]. + return union([boolean(), number(), string()], message); } type Literal = InferOutput>; diff --git a/packages/api-graph/src/private/schemas/NodeObject.spec.ts b/packages/api-graph/src/private/schemas/NodeObject.spec.ts index ee7ff575cb..46cc835c1a 100644 --- a/packages/api-graph/src/private/schemas/NodeObject.spec.ts +++ b/packages/api-graph/src/private/schemas/NodeObject.spec.ts @@ -1,27 +1,27 @@ -/// +// /// -import { scenario } from '@testduet/given-when-then'; -import { parse } from 'valibot'; -import { nodeObject } from './NodeObject'; +// import { scenario } from '@testduet/given-when-then'; +// import { parse } from 'valibot'; +// import { nodeObject } from './NodeObject'; -scenario('NodeObject', bdd => { - bdd.given - .oneOf([ - ['a boolean', () => [true, [true]]], - ['a number', () => [1, [1]]], - ['a string', () => ['abc', ['abc']]], - ['a null value', () => [null, [null]]], - ['a node object', () => [{ '@id': '_:2', value: 123 }, [{ '@id': '_:2', value: [123] }]]], - ['an array of boolean', () => [[true], [true]]], - ['an array of number', () => [[1], [1]]], - ['an array of string', () => [['abc'], ['abc']]], - ['an array of null value', () => [[null], [null]]], - ['an array of node object', () => [[{ '@id': '_:2', value: 123 }], [{ '@id': '_:2', value: [123] }]]] - ]) - .when('parsing value in a NodeObject', ([value]) => parse(nodeObject(), { '@id': '_:1', value })) - .then('should return expected value', ([_, expected], { value }) => expect(value).toEqual(expected)) - .and('node object should be frozen', (_, nodeObject) => expect(Object.isFrozen(nodeObject)).toBe(true)) - .and('value should be frozen', (_, nodeObject) => { - expect(Object.isFrozen(nodeObject['value'])).toBe(true); - }); -}); +// scenario('NodeObject', bdd => { +// bdd.given +// .oneOf([ +// ['a boolean', () => [true, [true]]], +// ['a number', () => [1, [1]]], +// ['a string', () => ['abc', ['abc']]], +// ['a null value', () => [null, [null]]], +// ['a node object', () => [{ '@id': '_:2', value: 123 }, [{ '@id': '_:2', value: [123] }]]], +// ['an array of boolean', () => [[true], [true]]], +// ['an array of number', () => [[1], [1]]], +// ['an array of string', () => [['abc'], ['abc']]], +// ['an array of null value', () => [[null], [null]]], +// ['an array of node object', () => [[{ '@id': '_:2', value: 123 }], [{ '@id': '_:2', value: [123] }]]] +// ]) +// .when('parsing value in a NodeObject', ([value]) => parse(nodeObject(), { '@id': '_:1', value })) +// .then('should return expected value', ([_, expected], { value }) => expect(value).toEqual(expected)) +// .and('node object should be frozen', (_, nodeObject) => expect(Object.isFrozen(nodeObject)).toBe(true)) +// .and('value should be frozen', (_, nodeObject) => { +// expect(Object.isFrozen(nodeObject['value'])).toBe(true); +// }); +// }); diff --git a/packages/api-graph/src/private/schemas/NodeObject.ts b/packages/api-graph/src/private/schemas/NodeObject.ts index 841dbc9154..6b3529041e 100644 --- a/packages/api-graph/src/private/schemas/NodeObject.ts +++ b/packages/api-graph/src/private/schemas/NodeObject.ts @@ -1,71 +1,71 @@ -// TODO: [P*] Maybe not used. -import { - array, - lazy, - objectWithRest, - optional, - pipe, - string, - transform, - union, - type ErrorMessage, - type GenericSchema -} from 'valibot'; +// // TODO: [P*] Maybe not used. +// import { +// array, +// lazy, +// objectWithRest, +// optional, +// pipe, +// string, +// transform, +// union, +// type ErrorMessage, +// type GenericSchema +// } from 'valibot'; -import identifier, { type Identifier } from './Identifier'; -import { literal, type Literal } from './Literal'; -import freeze from './private/freeze'; +// import identifier, { type Identifier } from './Identifier'; +// import { literal, type Literal } from './Literal'; +// import freeze from './private/freeze'; -type Input = { - '@context'?: string | undefined; - '@id'?: Identifier | undefined; - '@type'?: string | undefined; -} & { - [key: string]: Literal | Input | (Literal | Input)[]; -}; +// type Input = { +// '@context'?: string | undefined; +// '@id'?: Identifier | undefined; +// '@type'?: string | undefined; +// } & { +// [key: string]: Literal | Input | (Literal | Input)[]; +// }; -type NodeObject = { - readonly '@context'?: string | undefined; - readonly '@id'?: Identifier | undefined; - readonly '@type'?: string | undefined; -} & { - readonly [key: string]: readonly (Literal | NodeObject)[]; -}; +// type NodeObject = { +// readonly '@context'?: string | undefined; +// readonly '@id'?: Identifier | undefined; +// readonly '@type'?: string | undefined; +// } & { +// readonly [key: string]: readonly (Literal | NodeObject)[]; +// }; -/** - * Schema of JSON-LD node object. - * - * When parsed, all property value will be wrapped in an array. - * - * @param message - * @returns - */ -function nodeObject>( - message?: TMessage | undefined -): GenericSchema { - return pipe( - objectWithRest( - { - '@context': optional(string()), - '@id': optional(identifier()), - '@type': optional(string()) - }, - union([ - pipe(array(lazy(() => nodeObject())), freeze()), - pipe(array(literal()), freeze()), - pipe( - lazy(() => nodeObject()), - transform(value => Object.freeze([value])) - ), - pipe( - literal(), - transform(value => Object.freeze([value])) - ) - ]), - message - ), - freeze() - ); -} +// /** +// * Schema of JSON-LD node object. +// * +// * When parsed, all property value will be wrapped in an array. +// * +// * @param message +// * @returns +// */ +// function nodeObject>( +// message?: TMessage | undefined +// ): GenericSchema { +// return pipe( +// objectWithRest( +// { +// '@context': optional(string()), +// '@id': optional(identifier()), +// '@type': optional(string()) +// }, +// union([ +// pipe(array(lazy(() => nodeObject())), freeze()), +// pipe(array(literal()), freeze()), +// pipe( +// lazy(() => nodeObject()), +// transform(value => Object.freeze([value])) +// ), +// pipe( +// literal(), +// transform(value => Object.freeze([value])) +// ) +// ]), +// message +// ), +// freeze() +// ); +// } -export { nodeObject, type NodeObject }; +// export { nodeObject, type NodeObject }; diff --git a/packages/api-graph/src/private/schemas/expandArray.spec.ts b/packages/api-graph/src/private/schemas/colorNode.spec.ts similarity index 67% rename from packages/api-graph/src/private/schemas/expandArray.spec.ts rename to packages/api-graph/src/private/schemas/colorNode.spec.ts index 0c829ce788..890bbf1962 100644 --- a/packages/api-graph/src/private/schemas/expandArray.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.spec.ts @@ -1,6 +1,7 @@ +import { expect } from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; -import expandArray from './expandArray'; +import colorNode from './colorNode'; import type { FlatNodeObject } from './FlatNodeObject'; scenario('expandArray', bdd => { @@ -18,7 +19,7 @@ scenario('expandArray', bdd => { url: 'http://www.janedoe.com' }) satisfies FlatNodeObject ) - .when('expanded', value => expandArray(value)) + .when('expanded', value => colorNode(value)) .then('should wrap @type and property values in array', (_, actual) => { expect(actual).toEqual({ '@context': 'http://schema.org/', @@ -43,7 +44,7 @@ scenario('expandArray', bdd => { url: 'http://www.janedoe.com' }) satisfies FlatNodeObject ) - .when('expanded', value => expandArray(value)) + .when('expanded', value => colorNode(value)) .then('should wrap @type and property values in array', (_, actual) => { expect(actual).toEqual({ '@id': '_:b1', @@ -72,7 +73,7 @@ scenario('expandArray', bdd => { yield: '1 cocktail' }) satisfies FlatNodeObject ) - .when('expanded', value => expandArray(value)) + .when('expanded', value => colorNode(value)) .then('should wrap property values in array', (_, actual) => { expect(actual).toEqual({ '@id': '_:b1', @@ -98,8 +99,42 @@ scenario('expandArray', bdd => { '@type': ['HowTo', 'Message'] }) satisfies FlatNodeObject ) - .when('expnaded', value => expandArray(value)) + .when('expnaded', value => colorNode(value)) .then('should return both types', (_, actual) => { expect(actual['@type']).toEqual(['HowTo', 'Message']); }); + + bdd.given + .oneOf([ + [ + 'a node with hasPart as an array of string', + () => [{ '@id': '_:b1', hasPart: ['Hello, World!'] }, 'element in hasPart must be NodeReference'] + ], + [ + 'a node with hasPart as a string', + () => [{ '@id': '_:b1', hasPart: 'Hello, World!' }, 'element in hasPart must be NodeReference'] + ], + [ + 'a node with isPartOf as an array of string', + () => [{ '@id': '_:b1', isPartOf: ['Hello, World!'] }, 'element in isPartOf must be NodeReference'] + ], + [ + 'a node with isPartOf a string', + () => [{ '@id': '_:b1', isPartOf: 'Hello, World!' }, 'element in isPartOf must be NodeReference'] + ] + ]) + .when('colored', ([node]) => { + try { + colorNode(node); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', ([_, expectedMessage], error) => { + expect(() => { + throw error; + }).toThrow(expectedMessage); + }); }); diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts new file mode 100644 index 0000000000..367d7c2039 --- /dev/null +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -0,0 +1,111 @@ +import { + array, + null_, + objectWithRest, + optional, + parse, + pipe, + string, + union, + type ErrorMessage, + type InferOutput, + type ObjectSchema, + type ObjectWithRestIssue +} from 'valibot'; + +import type { FlatNodeObject } from './FlatNodeObject'; +import identifier from './Identifier'; +import { literal, type Literal } from './Literal'; +import { nodeReference, type NodeReference } from './NodeReference'; +import freeze from './private/freeze'; + +// Our opinions. +function slantNode | undefined = undefined>( + message?: TMessage | undefined +) { + return pipe( + objectWithRest( + { + // We only support limited scope of the JSON-LD specification. + '@context': optional(string('@context must be a string')), + '@id': identifier('@id must be a string'), + // "Multiple inheritance" is enabled by default. + '@type': optional( + pipe(array(string('element in @type must be a string'), '@type must be array of string'), freeze()) + ), + // We follow Schema.org that "hasPart" denotes children. + // This relationship is "membership" than "hierarchy". + hasPart: optional( + pipe( + array(nodeReference('element in hasPart must be NodeReference'), 'hasPart must be array of NodeReference'), + freeze() + ) + ), + // We follow Schema.org that "isPartOf" denotes parent, and multiple parent is possible. + // This relationship is "membership" than "hierarchy". + isPartOf: optional( + pipe( + array( + nodeReference('element in isPartOf must be NodeReference'), + 'isPartOf must be array of NodeReference' + ), + freeze() + ) + ) + }, + // The rest property values must be encapsulated in array, except when it's null. + // Array of boolean, number, string, and node reference are accepted. + union([array(literal()), array(nodeReference()), null_()]), + message + ), + freeze() + ); +} + +// Due to limitation on TypeScript, we cannot truthfully represent the typing. +type SlantNode = InferOutput['entries'], undefined>> & { + [key: string]: unknown; +}; + +/** + * Put our opinions into the node object. + * + * @param node + * @returns An opinionated node object which conforms to JSON-LD specification. + */ +function colorNode(node: FlatNodeObject): SlantNode { + const propertyMap = new Map(); + let context: string | undefined; + let id: string | undefined; + + for (const [key, value] of Object.entries(node)) { + const parsedValue = parse(union([array(nodeReference()), array(literal()), nodeReference(), literal()]), value); + + switch (key) { + case '@context': + context = parse(string(), value); + break; + + case '@id': + id = parse(string(), value); + break; + + default: + // TODO: [P*] Test mixed array with literal and node reference. + propertyMap.set(key, Array.isArray(parsedValue) ? parsedValue : Object.freeze([parsedValue])); + break; + } + } + + return parse( + slantNode(), + Object.fromEntries([ + ...(context ? [['@context', context]] : []), + ...(id ? [['@id', id]] : []), + ...Array.from(propertyMap) + ]) + ); +} + +export default colorNode; +export { slantNode, type SlantNode }; diff --git a/packages/api-graph/src/private/schemas/expandArray.ts b/packages/api-graph/src/private/schemas/expandArray.ts deleted file mode 100644 index 61e1f53272..0000000000 --- a/packages/api-graph/src/private/schemas/expandArray.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - array, - objectWithRest, - optional, - parse, - pipe, - string, - union, - type InferOutput, - type ObjectSchema -} from 'valibot'; - -import type { FlatNodeObject } from './FlatNodeObject'; -import identifier from './Identifier'; -import { literal, type Literal } from './Literal'; -import { nodeReference, type NodeReference } from './NodeReference'; -import freeze from './private/freeze'; - -const expandedFlatNodeObjectSchema = pipe( - objectWithRest( - { - '@context': optional(string()), - '@id': identifier(), - '@type': optional(array(string())) - }, - array(union([literal(), nodeReference()])) - ), - freeze() -); - -// Due to limitation on TypeScript, we cannot truthfully represent the typing. -// We believe this is the most faithful we can get. -// The other option would be use `Symbol` for `@context`/`@id`/`@type`. -type ExpandedFlatNodeObject = - | Readonly>> - | { readonly [key: string]: InferOutput }; - -/** - * Expands `@type` and all property values of a flat node object into array. - * - * Notes: - * - * - `@context` and `@id` are not expanded, they will be kept as string. - * - `@type` is expanded as array of string. - * - * @param node - * @returns A node object with property values expanded. - */ -function expandArray(node: FlatNodeObject): ExpandedFlatNodeObject { - const propertyMap = new Map(); - let context: string | undefined; - let id: string | undefined; - - for (const [key, value] of Object.entries(node)) { - const parsedValue = parse(union([array(nodeReference()), array(literal()), nodeReference(), literal()]), value); - - switch (key) { - case '@context': - context = parse(string(), value); - break; - - case '@id': - id = parse(string(), value); - break; - - default: - // TODO: [P*] Test mixed array with literal and node reference. - propertyMap.set(key, Array.isArray(parsedValue) ? parsedValue : Object.freeze([parsedValue])); - break; - } - } - - return parse( - expandedFlatNodeObjectSchema, - Object.fromEntries([ - ...(context ? [['@context', context]] : []), - ...(id ? [['@id', id]] : []), - ...Array.from(propertyMap) - ]) - ); -} - -export default expandArray; -export { expandedFlatNodeObjectSchema, type ExpandedFlatNodeObject }; diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts index d9f9bcf4bf..d34c480b13 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts @@ -1,4 +1,4 @@ -import { expect } from '@jest/globals'; +import { expect, jest } from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; import './expectExtendValibot'; diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts index adc51d944c..6b8e87e257 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -1,4 +1,4 @@ -import { expect } from '@jest/globals'; +import { afterEach, beforeEach, expect, jest } from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; import { array, assert, length, object, pipe } from 'valibot'; @@ -34,10 +34,6 @@ scenario('flattenNodeObject()', bdd => { [ 'a simple non-JSON-LD object with a property with value of type "boolean"', () => [{ value: true }, { value: true }] - ], - [ - 'a simple non-JSON-LD object with a property with value of type "null"', - () => [{ value: null }, { value: null }] ] ]) .when('flattened', ([value]) => flattenNodeObject(value)) From 858ecd73a0c6024a43376f773df2eb1e1d990e46 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 04:05:22 +0000 Subject: [PATCH 018/125] More opinions --- .../src/private/schemas/FlatNodeObject.ts | 14 +++- .../src/private/schemas/NodeObject.spec.ts | 27 ------- .../src/private/schemas/NodeObject.ts | 71 ------------------- .../src/private/schemas/colorNode.spec.ts | 27 +++++++ .../src/private/schemas/colorNode.ts | 43 ++++++++--- .../src/private/schemas/flattenNodeObject.ts | 2 + .../src/private/schemas/identifier.spec.ts | 21 ++++++ 7 files changed, 94 insertions(+), 111 deletions(-) delete mode 100644 packages/api-graph/src/private/schemas/NodeObject.spec.ts delete mode 100644 packages/api-graph/src/private/schemas/NodeObject.ts create mode 100644 packages/api-graph/src/private/schemas/identifier.spec.ts diff --git a/packages/api-graph/src/private/schemas/FlatNodeObject.ts b/packages/api-graph/src/private/schemas/FlatNodeObject.ts index d06e9af655..8147a8389c 100644 --- a/packages/api-graph/src/private/schemas/FlatNodeObject.ts +++ b/packages/api-graph/src/private/schemas/FlatNodeObject.ts @@ -1,4 +1,14 @@ -import { array, objectWithRest, optional, pipe, string, union, type ErrorMessage, type InferOutput } from 'valibot'; +import { + array, + null_, + objectWithRest, + optional, + pipe, + string, + union, + type ErrorMessage, + type InferOutput +} from 'valibot'; import identifier from './Identifier'; import { literal } from './Literal'; @@ -7,7 +17,7 @@ import freeze from './private/freeze'; function flatNodeObjectPropertyValue>(message?: TMessage | undefined) { return union( - [pipe(array(union([literal(), nodeReference()])), freeze()), pipe(literal()), pipe(nodeReference())], + [pipe(array(union([literal(), nodeReference()])), freeze()), pipe(literal()), pipe(nodeReference()), null_()], message ); } diff --git a/packages/api-graph/src/private/schemas/NodeObject.spec.ts b/packages/api-graph/src/private/schemas/NodeObject.spec.ts deleted file mode 100644 index 46cc835c1a..0000000000 --- a/packages/api-graph/src/private/schemas/NodeObject.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -// /// - -// import { scenario } from '@testduet/given-when-then'; -// import { parse } from 'valibot'; -// import { nodeObject } from './NodeObject'; - -// scenario('NodeObject', bdd => { -// bdd.given -// .oneOf([ -// ['a boolean', () => [true, [true]]], -// ['a number', () => [1, [1]]], -// ['a string', () => ['abc', ['abc']]], -// ['a null value', () => [null, [null]]], -// ['a node object', () => [{ '@id': '_:2', value: 123 }, [{ '@id': '_:2', value: [123] }]]], -// ['an array of boolean', () => [[true], [true]]], -// ['an array of number', () => [[1], [1]]], -// ['an array of string', () => [['abc'], ['abc']]], -// ['an array of null value', () => [[null], [null]]], -// ['an array of node object', () => [[{ '@id': '_:2', value: 123 }], [{ '@id': '_:2', value: [123] }]]] -// ]) -// .when('parsing value in a NodeObject', ([value]) => parse(nodeObject(), { '@id': '_:1', value })) -// .then('should return expected value', ([_, expected], { value }) => expect(value).toEqual(expected)) -// .and('node object should be frozen', (_, nodeObject) => expect(Object.isFrozen(nodeObject)).toBe(true)) -// .and('value should be frozen', (_, nodeObject) => { -// expect(Object.isFrozen(nodeObject['value'])).toBe(true); -// }); -// }); diff --git a/packages/api-graph/src/private/schemas/NodeObject.ts b/packages/api-graph/src/private/schemas/NodeObject.ts deleted file mode 100644 index 6b3529041e..0000000000 --- a/packages/api-graph/src/private/schemas/NodeObject.ts +++ /dev/null @@ -1,71 +0,0 @@ -// // TODO: [P*] Maybe not used. -// import { -// array, -// lazy, -// objectWithRest, -// optional, -// pipe, -// string, -// transform, -// union, -// type ErrorMessage, -// type GenericSchema -// } from 'valibot'; - -// import identifier, { type Identifier } from './Identifier'; -// import { literal, type Literal } from './Literal'; -// import freeze from './private/freeze'; - -// type Input = { -// '@context'?: string | undefined; -// '@id'?: Identifier | undefined; -// '@type'?: string | undefined; -// } & { -// [key: string]: Literal | Input | (Literal | Input)[]; -// }; - -// type NodeObject = { -// readonly '@context'?: string | undefined; -// readonly '@id'?: Identifier | undefined; -// readonly '@type'?: string | undefined; -// } & { -// readonly [key: string]: readonly (Literal | NodeObject)[]; -// }; - -// /** -// * Schema of JSON-LD node object. -// * -// * When parsed, all property value will be wrapped in an array. -// * -// * @param message -// * @returns -// */ -// function nodeObject>( -// message?: TMessage | undefined -// ): GenericSchema { -// return pipe( -// objectWithRest( -// { -// '@context': optional(string()), -// '@id': optional(identifier()), -// '@type': optional(string()) -// }, -// union([ -// pipe(array(lazy(() => nodeObject())), freeze()), -// pipe(array(literal()), freeze()), -// pipe( -// lazy(() => nodeObject()), -// transform(value => Object.freeze([value])) -// ), -// pipe( -// literal(), -// transform(value => Object.freeze([value])) -// ) -// ]), -// message -// ), -// freeze() -// ); -// } - -// export { nodeObject, type NodeObject }; diff --git a/packages/api-graph/src/private/schemas/colorNode.spec.ts b/packages/api-graph/src/private/schemas/colorNode.spec.ts index 890bbf1962..a68a15935f 100644 --- a/packages/api-graph/src/private/schemas/colorNode.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.spec.ts @@ -137,4 +137,31 @@ scenario('expandArray', bdd => { throw error; }).toThrow(expectedMessage); }); + + bdd.given + .oneOf([ + ['a node with a property value of null', () => ({ '@id': '_:b1', value: null })], + ['a node with a property value of empty array', () => ({ '@id': '_:b1', value: [] })] + ]) + .when('colored', node => colorNode(node)) + .then('the property should be removed', (_, slantNode) => { + expect(slantNode).toEqual({ '@id': '_:b1' }); + }); + + bdd + .given( + 'a node with a property value mixed with literal and node reference', + () => + ({ + '@id': '_:b1', + value: ['Hello, World!', { '@id': '_:b2' }, 123, true] + }) satisfies FlatNodeObject + ) + .when('colored', node => colorNode(node)) + .then('should convert', (_, slantNode) => { + expect(slantNode).toEqual({ + '@id': '_:b1', + value: ['Hello, World!', { '@id': '_:b2' }, 123, true] + }); + }); }); diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 367d7c2039..147e49f208 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -1,5 +1,6 @@ import { array, + minLength, null_, objectWithRest, optional, @@ -53,9 +54,9 @@ function slantNode | undefine ) ) }, - // The rest property values must be encapsulated in array, except when it's null. + // The rest property values must be encapsulated in array. // Array of boolean, number, string, and node reference are accepted. - union([array(literal()), array(nodeReference()), null_()]), + pipe(array(union([literal(), nodeReference()])), minLength(1)), message ), freeze() @@ -68,7 +69,22 @@ type SlantNode = InferOutput['entries' }; /** - * Put our opinions into the node object. + * Put our opinions into the node. + * + * The opinions are targeted around a few principles: + * + * - Simplifying downstream logics + * - Must have `@id` + * - Uniform getter/setter: every property value is an array, except `@context` and `@id` + * - Uniform typing: node reference must be `{ "@id": string }` to reduce confusion with a plain string + * - Support multiple types: every `@type` must be an array of string + * - Flattened: property values must be non-null literals or node reference, not object + * - Will throw with value of `[null]` as they do not convey any meanings + * - Do not handle full JSON-LD spec: `@context` is an opaque string + * - Reduce confusion: empty array and null is removed + * - `[]` and `null` are same as if the property is removed + * - Auto-linking for Schema.org (`hasPart` and `isPartOf` are auto-inversed) + * - Keep its root: every node is compliant to JSON-LD, understood by standard parsers * * @param node * @returns An opinionated node object which conforms to JSON-LD specification. @@ -79,7 +95,10 @@ function colorNode(node: FlatNodeObject): SlantNode { let id: string | undefined; for (const [key, value] of Object.entries(node)) { - const parsedValue = parse(union([array(nodeReference()), array(literal()), nodeReference(), literal()]), value); + const parsedValue = parse( + union([array(union([literal(), nodeReference()])), nodeReference(), literal(), null_()]), + value + ); switch (key) { case '@context': @@ -90,20 +109,22 @@ function colorNode(node: FlatNodeObject): SlantNode { id = parse(string(), value); break; - default: + default: { // TODO: [P*] Test mixed array with literal and node reference. - propertyMap.set(key, Array.isArray(parsedValue) ? parsedValue : Object.freeze([parsedValue])); + const slantValue = Array.isArray(parsedValue) + ? parsedValue + : Object.freeze(parsedValue === null ? [] : [parsedValue]); + + slantValue.length && propertyMap.set(key, slantValue); + break; + } } } return parse( slantNode(), - Object.fromEntries([ - ...(context ? [['@context', context]] : []), - ...(id ? [['@id', id]] : []), - ...Array.from(propertyMap) - ]) + Object.fromEntries([...(context ? [['@context', context]] : []), ['@id', id], ...Array.from(propertyMap)]) ); } diff --git a/packages/api-graph/src/private/schemas/flattenNodeObject.ts b/packages/api-graph/src/private/schemas/flattenNodeObject.ts index 8c9e30dc6b..85242392fe 100644 --- a/packages/api-graph/src/private/schemas/flattenNodeObject.ts +++ b/packages/api-graph/src/private/schemas/flattenNodeObject.ts @@ -1,3 +1,5 @@ +// TODO: [P0] This flattening can probably fold into `colorNode()` as it has slanted view of the system. + import { object, optional, parse, safeParse } from 'valibot'; import flatNodeObject, { type FlatNodeObject, type FlatNodeObjectPropertyValue } from './FlatNodeObject'; diff --git a/packages/api-graph/src/private/schemas/identifier.spec.ts b/packages/api-graph/src/private/schemas/identifier.spec.ts new file mode 100644 index 0000000000..971ffc58ae --- /dev/null +++ b/packages/api-graph/src/private/schemas/identifier.spec.ts @@ -0,0 +1,21 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { safeParse } from 'valibot'; +import identifier from './Identifier'; + +scenario('identifier()', bdd => { + bdd + .given('an empty string', () => '') + .when('parsed', value => safeParse(identifier(), value)) + .then('should fail', (_, result) => expect(result).toHaveProperty('success', false)); + + bdd + .given('a blank node identifier', () => '_:b1') + .when('parsed', value => safeParse(identifier(), value)) + .then('should success', (_, result) => expect(result).toHaveProperty('success', true)); + + bdd + .given('a URL', () => 'https://aka.ms') + .when('parsed', value => safeParse(identifier(), value)) + .then('should success', (_, result) => expect(result).toHaveProperty('success', true)); +}); From 3b2229fcd387d5e6de99aaccc5a049e5ef05a5e2 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 04:38:39 +0000 Subject: [PATCH 019/125] Update comments --- .../api-graph/src/private/schemas/colorNode.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 147e49f208..f82a3d889e 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -74,16 +74,17 @@ type SlantNode = InferOutput['entries' * The opinions are targeted around a few principles: * * - Simplifying downstream logics - * - Must have `@id` + * - Must have `@id`: every node in the graph must be identifiable * - Uniform getter/setter: every property value is an array, except `@context` and `@id` * - Uniform typing: node reference must be `{ "@id": string }` to reduce confusion with a plain string * - Support multiple types: every `@type` must be an array of string - * - Flattened: property values must be non-null literals or node reference, not object - * - Will throw with value of `[null]` as they do not convey any meanings - * - Do not handle full JSON-LD spec: `@context` is an opaque string - * - Reduce confusion: empty array and null is removed - * - `[]` and `null` are same as if the property is removed - * - Auto-linking for Schema.org (`hasPart` and `isPartOf` are auto-inversed) + * - Reduce confusion: empty array and `null` is removed + * - `[]` and `null` are same as if the property is removed + * - Flattened: property values must be non-null literals or node reference, no nested objects + * - `null` will be converted to [] and eventually the property will be removed + * - Any array containing `null` is not supported and will throw, as it is likely a bug in code + * - Do not handle full JSON-LD spec: `@context` is an opaque string and the schema is not honored + * - Auto-linking for Schema.org: `hasPart` and `isPartOf` are auto-inversed * - Keep its root: every node is compliant to JSON-LD, understood by standard parsers * * @param node From 3a4b2a6ab5f39c6fd6fab33e4d27c2d62c609a69 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 04:40:37 +0000 Subject: [PATCH 020/125] Clean up --- packages/api-graph/src/private/schemas/colorNode.spec.ts | 4 ++-- packages/api-graph/src/private/schemas/colorNode.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.spec.ts b/packages/api-graph/src/private/schemas/colorNode.spec.ts index a68a15935f..a6f5c9f366 100644 --- a/packages/api-graph/src/private/schemas/colorNode.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.spec.ts @@ -154,14 +154,14 @@ scenario('expandArray', bdd => { () => ({ '@id': '_:b1', - value: ['Hello, World!', { '@id': '_:b2' }, 123, true] + value: ['Hello, World!', { '@id': '_:b2' }, 0, false] }) satisfies FlatNodeObject ) .when('colored', node => colorNode(node)) .then('should convert', (_, slantNode) => { expect(slantNode).toEqual({ '@id': '_:b1', - value: ['Hello, World!', { '@id': '_:b2' }, 123, true] + value: ['Hello, World!', { '@id': '_:b2' }, 0, false] }); }); }); diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index f82a3d889e..7fe9726ed0 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -111,12 +111,11 @@ function colorNode(node: FlatNodeObject): SlantNode { break; default: { - // TODO: [P*] Test mixed array with literal and node reference. - const slantValue = Array.isArray(parsedValue) + const slantedValue = Array.isArray(parsedValue) ? parsedValue : Object.freeze(parsedValue === null ? [] : [parsedValue]); - slantValue.length && propertyMap.set(key, slantValue); + slantedValue.length && propertyMap.set(key, slantedValue); break; } From f69b9b07f34bbee1937e8ff401edc33fe47e138d Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 05:15:25 +0000 Subject: [PATCH 021/125] Add test --- .../src/private/Graph.isPartOf.spec.ts | 32 +++++++++++++++++++ packages/api-graph/src/private/Graph.spec.ts | 17 ++++++++++ packages/api-graph/src/private/Graph.ts | 10 ++++-- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/api-graph/src/private/Graph.isPartOf.spec.ts b/packages/api-graph/src/private/Graph.isPartOf.spec.ts index 8537433ffc..36bc687d94 100644 --- a/packages/api-graph/src/private/Graph.isPartOf.spec.ts +++ b/packages/api-graph/src/private/Graph.isPartOf.spec.ts @@ -74,5 +74,37 @@ scenario('Graph class', bdd => { ids: ['_:m2', '_:m3', '_:c1'] } }); + }) + .when('a Message is disconnected from the Conversation', ({ graph }) => { + graph.upsert({ + '@id': '_:m1', + '@type': ['Message'], + text: ['Hello, World!'] + }); + }) + .then('the Conversation.hasPart should have the Message unreferenced', ({ graph }) => { + expect(Array.from(graph.snapshot().entries())).toEqual([ + [ + '_:c1', + { + '@id': '_:c1', + '@type': ['Conversation'], + hasPart: [{ '@id': '_:m2' }, { '@id': '_:m3' }] + } + ], + ['_:m1', { '@id': '_:m1', '@type': ['Message'], text: ['Hello, World!'] }], + ['_:m2', { '@id': '_:m2', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Aloha!'] }], + ['_:m3', { '@id': '_:m3', '@type': ['Message'], isPartOf: [{ '@id': '_:c1' }], text: ['Good morning!'] }] + ]); + }) + .and('observer should receive the detached Message node and Conversation node', async ({ iterator }) => { + await iterator.next(); // Skip the first change. + await iterator.next(); // Skip the second change. + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + ids: ['_:m1', '_:c1'] + } + }); }); }); diff --git a/packages/api-graph/src/private/Graph.spec.ts b/packages/api-graph/src/private/Graph.spec.ts index 707847a26f..ddb005232f 100644 --- a/packages/api-graph/src/private/Graph.spec.ts +++ b/packages/api-graph/src/private/Graph.spec.ts @@ -22,4 +22,21 @@ scenario('Graph class', bdd => { expect(result).toEqual({ done: false, value: { ids: ['_:b1'] } }); }); + + bdd + .given('a graph', () => new Graph()) + .when('upserting 2 nodes with same @id', graph => { + try { + graph.upsert({ '@id': '_:b1' } satisfies SlantNode, { '@id': '_:b1' } satisfies SlantNode); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + throw error; + }).toThrow('Cannot upsert two or more nodes with same @id'); + }); }); diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 6fd3168e33..16e2fecd93 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -57,9 +57,15 @@ class Graph extends EventTarget { const affectedIdSet: Set = new Set(); for (const node of nodes) { - this.#setGraphNode(node); + const id = node['@id']; + + if (affectedIdSet.has(id)) { + throw new Error('Cannot upsert two or more nodes with same @id'); + } - affectedIdSet.add(node['@id']); + affectedIdSet.add(id); + + this.#setGraphNode(node); } for (const node of nodes) { From bbd7e0c17e0d7e2fb25e1f14d67ee72001cf23a9 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 06:39:46 +0000 Subject: [PATCH 022/125] Remove edge if needed --- .../src/private/Graph.isPartOf.spec.ts | 61 +++++++ packages/api-graph/src/private/Graph.ts | 166 ++++++++++++++---- .../src/private/schemas/colorNode.spec.ts | 10 ++ .../src/private/schemas/colorNode.ts | 21 ++- 4 files changed, 217 insertions(+), 41 deletions(-) diff --git a/packages/api-graph/src/private/Graph.isPartOf.spec.ts b/packages/api-graph/src/private/Graph.isPartOf.spec.ts index 36bc687d94..e01609cbe5 100644 --- a/packages/api-graph/src/private/Graph.isPartOf.spec.ts +++ b/packages/api-graph/src/private/Graph.isPartOf.spec.ts @@ -106,5 +106,66 @@ scenario('Graph class', bdd => { ids: ['_:m1', '_:c1'] } }); + }) + .when('the Conversation detached all Message', ({ graph }) => { + graph.upsert({ + '@id': '_:c1', + '@type': ['Conversation'] + }); + }) + .then('all nodes should be disconnected', ({ graph }) => { + expect(Array.from(graph.snapshot().entries())).toEqual([ + [ + '_:c1', + { + '@id': '_:c1', + '@type': ['Conversation'] + } + ], + ['_:m1', { '@id': '_:m1', '@type': ['Message'], text: ['Hello, World!'] }], + ['_:m2', { '@id': '_:m2', '@type': ['Message'], text: ['Aloha!'] }], + ['_:m3', { '@id': '_:m3', '@type': ['Message'], text: ['Good morning!'] }] + ]); + }) + .and('observer should receive the detached Message node and Conversation node', async ({ iterator }) => { + await iterator.next(); // Skip the first change. + await iterator.next(); // Skip the second change. + await iterator.next(); // Skip the third change. + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + ids: ['_:c1', '_:m2', '_:m3'] + } + }); + }); + + bdd + .given('a graph with a Conversation node with a Message', () => { + const graph = new Graph(); + + graph.upsert({ '@id': '_:c1', '@type': ['Conversation'] } satisfies SlantNode); + graph.upsert({ + '@id': '_:m1', + '@type': ['Message'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Hello, World!'] + } satisfies SlantNode); + + return { graph }; + }) + .and('an observer', condition => ({ ...condition, iterator: condition.graph.observe() })) + .when('the Message node is updated', ({ graph }) => { + graph.upsert({ + '@id': '_:m1', + '@type': ['Message'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Aloha!'] + } satisfies SlantNode); + }) + .then('the observer should only return the Message', async ({ iterator }) => { + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { ids: ['_:m1'] } + }); }); }); diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 16e2fecd93..0a751522ec 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -8,11 +8,22 @@ import { parse } from 'valibot'; -import { slantNode, type SlantNode } from './schemas/colorNode'; +import colorNode, { type SlantNode } from './schemas/colorNode'; import type { Identifier } from './schemas/Identifier'; +import { nodeReference, type NodeReference } from './schemas/NodeReference'; type GraphChangeEvent = { readonly ids: readonly Identifier[] }; +function nodeReferenceListToIdentifierSet(nodeReferences: readonly NodeReference[]): Set { + return new Set(nodeReferences.map(ref => ref['@id'])); +} + +function identifierSetToNodeReferenceList(identifierSet: ReadonlySet): readonly NodeReference[] { + return Object.freeze( + Array.from(identifierSet.values().map(identifier => parse(nodeReference(), { '@id': identifier }))) + ); +} + class Graph extends EventTarget { constructor() { super(); @@ -25,7 +36,8 @@ class Graph extends EventTarget { #observerControllerSet: Set>; #setGraphNode(node: SlantNode) { - const safeNode = parse(slantNode(), node); + // We need to recolor the node as it could lose its color over time, e.g. empty array -> remove. + const safeNode = colorNode(node); // eslint-disable-next-line no-restricted-syntax this.#graph.set(safeNode['@id'], safeNode); @@ -53,6 +65,100 @@ class Graph extends EventTarget { return new Map(this.#graph); } + #setEdge( + subjectId: Identifier, + linkType: 'hasPart' | 'isPartOf', + objectId: Identifier, + operation: 'add' | 'delete' + ): ReadonlySet { + const subject = this.#graph.get(subjectId); + + if (!subject) { + throw new Error(`Cannot find subject with @id of "${subjectId}"`); + } + + const object = this.#graph.get(objectId); + + if (!object) { + throw new Error(`Cannot find object with @id of "${objectId}"`); + } + + let nextObject: SlantNode | undefined; + let nextSubject: SlantNode | undefined; + + const objectHasPart = nodeReferenceListToIdentifierSet(object.hasPart || []); + const objectIsPartOf = nodeReferenceListToIdentifierSet(object.isPartOf || []); + const subjectHasPart = nodeReferenceListToIdentifierSet(subject.hasPart || []); + const subjectIsPartOf = nodeReferenceListToIdentifierSet(subject.isPartOf || []); + + if (linkType === 'hasPart') { + if (operation === 'add') { + if (!subjectHasPart.has(objectId)) { + subjectHasPart.add(objectId); + nextSubject = { ...subject, hasPart: identifierSetToNodeReferenceList(subjectHasPart) }; + } + + if (!objectIsPartOf.has(subjectId)) { + objectIsPartOf.add(subjectId); + nextObject = { ...object, isPartOf: identifierSetToNodeReferenceList(objectIsPartOf) }; + } + } else { + operation satisfies 'delete'; + + if (subjectHasPart.has(objectId)) { + subjectHasPart.delete(objectId); + nextSubject = { ...subject, hasPart: identifierSetToNodeReferenceList(subjectHasPart) }; + } + + if (objectIsPartOf.has(subjectId)) { + objectIsPartOf.delete(subjectId); + nextObject = { ...object, isPartOf: identifierSetToNodeReferenceList(objectIsPartOf) }; + } + } + } else { + linkType satisfies 'isPartOf'; + + if (operation === 'add') { + if (!subjectIsPartOf.has(objectId)) { + subjectIsPartOf.add(objectId); + nextSubject = { ...subject, isPartOf: identifierSetToNodeReferenceList(subjectIsPartOf) }; + } + + if (!objectHasPart.has(subjectId)) { + objectHasPart.add(subjectId); + nextObject = { ...object, hasPart: identifierSetToNodeReferenceList(objectHasPart) }; + } + } else { + operation satisfies 'delete'; + + if (subjectIsPartOf.has(objectId)) { + subjectIsPartOf.delete(objectId); + nextSubject = { ...subject, isPartOf: identifierSetToNodeReferenceList(subjectIsPartOf) }; + } + + if (objectHasPart.has(subjectId)) { + objectHasPart.delete(subjectId); + nextObject = { ...object, hasPart: identifierSetToNodeReferenceList(objectHasPart) }; + } + } + } + + const affectedIds = new Set(); + + if (nextObject) { + this.#setGraphNode(nextObject); + affectedIds.add(nextObject['@id']); + } + + if (nextSubject) { + this.#setGraphNode(nextSubject); + affectedIds.add(nextSubject['@id']); + } + + return Object.freeze(affectedIds); + } + + // eslint-disable-next-line complexity upsert(...nodes: readonly SlantNode[]) { const affectedIdSet: Set = new Set(); @@ -65,6 +171,26 @@ class Graph extends EventTarget { affectedIdSet.add(id); + const existingNode = this.#graph.get(id); + + if (existingNode) { + for (const existingChildId of nodeReferenceListToIdentifierSet(existingNode.hasPart || []).difference( + nodeReferenceListToIdentifierSet(node.hasPart || []) + )) { + for (const id of this.#setEdge(existingNode['@id'], 'hasPart', existingChildId, 'delete')) { + affectedIdSet.add(id); + } + } + + for (const existingParentId of nodeReferenceListToIdentifierSet(existingNode.isPartOf || []).difference( + nodeReferenceListToIdentifierSet(node.isPartOf || []) + )) { + for (const id of this.#setEdge(existingNode['@id'], 'isPartOf', existingParentId, 'delete')) { + affectedIdSet.add(id); + } + } + } + this.#setGraphNode(node); } @@ -72,42 +198,14 @@ class Graph extends EventTarget { const nodeId = node['@id']; for (const { '@id': childId } of node.hasPart || []) { - const child = this.#graph.get(childId); - - if (!child) { - throw new Error(`Cannot find node denoted by node[@id="${nodeId}"].hasPart[@id="${childId}"]`, { - cause: { node } - }); - } - - const parentIds: Set = new Set(child.isPartOf?.map(ref => ref['@id']) || []); - - if (!parentIds.has(nodeId)) { - parentIds.add(nodeId); - - this.#setGraphNode({ ...child, isPartOf: Array.from(parentIds.values()).map(id => ({ '@id': id })) }); - - affectedIdSet.add(child['@id']); + for (const id of this.#setEdge(nodeId, 'hasPart', childId, 'add')) { + affectedIdSet.add(id); } } for (const { '@id': parentId } of node.isPartOf || []) { - const parent = this.#graph.get(parentId); - - if (!parent) { - throw new Error(`Cannot find node denoted by node[@id="${nodeId}"].isPartOf[@id="${parentId}"]`, { - cause: { node } - }); - } - - const parentIds: Set = new Set(parent.hasPart?.map(ref => ref['@id']) || []); - - if (!parentIds.has(nodeId)) { - parentIds.add(nodeId); - - this.#setGraphNode({ ...parent, hasPart: Array.from(parentIds.values()).map(id => ({ '@id': id })) }); - - affectedIdSet.add(parent['@id']); + for (const id of this.#setEdge(nodeId, 'isPartOf', parentId, 'add')) { + affectedIdSet.add(id); } } } diff --git a/packages/api-graph/src/private/schemas/colorNode.spec.ts b/packages/api-graph/src/private/schemas/colorNode.spec.ts index a6f5c9f366..8a786e28bb 100644 --- a/packages/api-graph/src/private/schemas/colorNode.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.spec.ts @@ -148,6 +148,16 @@ scenario('expandArray', bdd => { expect(slantNode).toEqual({ '@id': '_:b1' }); }); + bdd.given + .oneOf([ + ['a node with hasPart of empty array', () => ({ '@id': '_:b1', hasPart: [] })], + ['a node with isPartOf of empty array', () => ({ '@id': '_:b1', isPartOf: [] })] + ]) + .when('colored', node => colorNode(node)) + .then('the property should be removed', (_, slantNode) => { + expect(slantNode).toEqual({ '@id': '_:b1' }); + }); + bdd .given( 'a node with a property value mixed with literal and node reference', diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 7fe9726ed0..47c7edee0a 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -27,19 +27,24 @@ function slantNode | undefine return pipe( objectWithRest( { - // We only support limited scope of the JSON-LD specification. + // We treat @context as opaque string than a schema. '@context': optional(string('@context must be a string')), - '@id': identifier('@id must be a string'), - // "Multiple inheritance" is enabled by default. + '@id': identifier('@id is required and must be a string'), + // Multi-membership is enabled by default. '@type': optional( - pipe(array(string('element in @type must be a string'), '@type must be array of string'), freeze()) + pipe( + array(string('element in @type must be a string'), '@type must be array of string'), + freeze(), + minLength(1, '@type must have at least one element') + ) ), // We follow Schema.org that "hasPart" denotes children. // This relationship is "membership" than "hierarchy". hasPart: optional( pipe( array(nodeReference('element in hasPart must be NodeReference'), 'hasPart must be array of NodeReference'), - freeze() + freeze(), + minLength(1, 'hasPart, if present, must have at least one element') ) ), // We follow Schema.org that "isPartOf" denotes parent, and multiple parent is possible. @@ -50,7 +55,8 @@ function slantNode | undefine nodeReference('element in isPartOf must be NodeReference'), 'isPartOf must be array of NodeReference' ), - freeze() + freeze(), + minLength(1, 'isPartOf, if present, must have at least one element') ) ) }, @@ -86,11 +92,12 @@ type SlantNode = InferOutput['entries' * - Do not handle full JSON-LD spec: `@context` is an opaque string and the schema is not honored * - Auto-linking for Schema.org: `hasPart` and `isPartOf` are auto-inversed * - Keep its root: every node is compliant to JSON-LD, understood by standard parsers + * - Debuggability: must have at least one `@type` * * @param node * @returns An opinionated node object which conforms to JSON-LD specification. */ -function colorNode(node: FlatNodeObject): SlantNode { +function colorNode(node: FlatNodeObject | SlantNode): SlantNode { const propertyMap = new Map(); let context: string | undefined; let id: string | undefined; From 66bc3aafe0a1476d5a99cae4e9c8a2fd00eef0b1 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:09:21 +0000 Subject: [PATCH 023/125] Add specific test case for coloring --- packages/api-graph/src/private/Graph.spec.ts | 22 +- .../private/schemas/colorNode.opinion.spec.ts | 206 ++++++++++++++++++ .../src/private/schemas/colorNode.spec.ts | 114 ++-------- .../src/private/schemas/colorNode.ts | 10 +- 4 files changed, 252 insertions(+), 100 deletions(-) create mode 100644 packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts diff --git a/packages/api-graph/src/private/Graph.spec.ts b/packages/api-graph/src/private/Graph.spec.ts index ddb005232f..ff3f6b6fef 100644 --- a/packages/api-graph/src/private/Graph.spec.ts +++ b/packages/api-graph/src/private/Graph.spec.ts @@ -8,14 +8,27 @@ scenario('Graph class', bdd => { .given('a graph', () => ({ graph: new Graph() })) .and('a node', ({ graph }) => ({ graph, - node: { '@id': '_:b1', value: ['abc'] } satisfies SlantNode + node: { + '@id': '_:b1', + '@type': ['Person'], + value: ['abc'] + } satisfies SlantNode })) .and('an observer', condition => ({ ...condition, iterator: condition.graph.observe() })) .when('upserted', ({ graph, node }) => { graph.upsert(node); }) .then('the graph should have the node', ({ graph }) => { - expect(Array.from(graph.snapshot().entries())).toEqual([['_:b1', { '@id': '_:b1', value: ['abc'] }]]); + expect(Array.from(graph.snapshot().entries())).toEqual([ + [ + '_:b1', + { + '@id': '_:b1', + '@type': ['Person'], + value: ['abc'] + } + ] + ]); }) .and('observer', async ({ iterator }) => { const result = await iterator.next(); @@ -27,7 +40,10 @@ scenario('Graph class', bdd => { .given('a graph', () => new Graph()) .when('upserting 2 nodes with same @id', graph => { try { - graph.upsert({ '@id': '_:b1' } satisfies SlantNode, { '@id': '_:b1' } satisfies SlantNode); + graph.upsert( + { '@id': '_:b1', '@type': ['Person'] } satisfies SlantNode, + { '@id': '_:b1', '@type': ['Person'] } satisfies SlantNode + ); } catch (error) { return error; } diff --git a/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts b/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts new file mode 100644 index 0000000000..1e4659b86b --- /dev/null +++ b/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts @@ -0,0 +1,206 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import colorNode, { type SlantNode } from './colorNode'; + +function executeWhen([node]: [unknown, string | SlantNode]): [unknown] | [undefined, SlantNode] { + let result: SlantNode; + + try { + result = colorNode(node as any); + } catch (error) { + return [error]; + } + + return [undefined, result]; +} + +function executeThen([_, errorOrResult]: [unknown, string | SlantNode], result: [unknown] | [undefined, SlantNode]) { + if (typeof errorOrResult === 'string') { + expect(() => { + if (result[0]) { + throw result[0]; + } + }).toThrow(errorOrResult); + } else { + expect(result[1]).toEqual(errorOrResult); + } +} + +scenario('Must have `@id`: every node in the graph must be identifiable', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with @id', + () => [ + { '@id': '_:b1', '@type': ['Person'] }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + ['node with @id of empty string', () => [{ '@id': '' }, '@id is required and must be a string']], + ['node without @id', () => [{}, '@id is required and must be a string']] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); + +scenario('Uniform getter/setter: every property value is an array, except `@context` and `@id`', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with literal value in plain', + () => [ + { '@context': 'https://schema.org', '@id': '_:b1', '@type': ['Person'], name: 'John Doe' }, + { '@context': 'https://schema.org', '@id': '_:b1', '@type': ['Person'], name: ['John Doe'] } + ] + ], + [ + 'node with literal value in array', + () => [ + { '@context': 'https://schema.org', '@id': '_:b1', '@type': ['Person'], name: ['John Doe'] }, + { '@context': 'https://schema.org', '@id': '_:b1', '@type': ['Person'], name: ['John Doe'] } + ] + ] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); + +scenario('Uniform typing: node reference must be `{ "@id": string }` to reduce confusion with a plain string', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with reference', + () => [ + { '@id': '_:b1', '@type': ['Person'], name: { '@id': '_:b2' } }, + { '@id': '_:b1', '@type': ['Person'], name: [{ '@id': '_:b2' }] } + ] + ] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); + +scenario('Support multiple types: every `@type` must be an array of string', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with literal value in plain', + () => [ + { '@id': '_:b1', '@type': 'Person' }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with literal value in array', + () => [ + { '@id': '_:b1', '@type': ['Person'] }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); + +scenario('Reduce confusion: empty array and `null` is removed', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with empty array', + () => [ + { '@id': '_:b1', '@type': ['Person'], value: [] }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with null', + () => [ + { '@id': '_:b1', '@type': ['Person'], value: null }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with array of null', + () => [ + { '@id': '_:b1', '@type': ['Person'], value: [null] }, + 'Invalid type: Expected (Array | Object | (boolean | number | string) | null) but received Array' + ] + ], + [ + 'node with hasPart of empty array', + () => [ + { '@id': '_:b1', '@type': ['Person'], hasPart: [] }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with hasPart of null', + () => [ + { '@id': '_:b1', '@type': ['Person'], hasPart: null }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with isPartOf of empty array', + () => [ + { '@id': '_:b1', '@type': ['Person'], isPartOf: [] }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with isPartOf of null', + () => [ + { '@id': '_:b1', '@type': ['Person'], isPartOf: null }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); + +// scenario('Flattened: property values must be non-null literals or node reference, no nested objects', () => { +// // TODO: Need to move flattenNodeObject into colorNode. +// }); + +scenario('Do not handle full JSON-LD spec: `@context` is an opaque string and the schema is not honored', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with @context of string', + () => [ + { '@context': 'https://schema.org', '@id': '_:b1', '@type': ['Person'] }, + { '@context': 'https://schema.org', '@id': '_:b1', '@type': ['Person'] } + ] + ], + [ + 'node with @context of object', + () => [ + { '@context': {}, '@id': '_:b1', '@type': ['Person'] }, + 'Invalid type: Expected (Array | Object | (boolean | number | string) | null) but received Object' + ] + ] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); + +// scenario('Auto-linking for Schema.org: `hasPart` and `isPartOf` are auto-inversed', bdd => { +// // TODO: This is a feature of Graph. +// }); + +scenario('Debuggability: must have at least one `@type`', bdd => { + bdd.given + .oneOf<[unknown, string | SlantNode]>([ + [ + 'node with @type', + () => [ + { '@id': '_:b1', '@type': ['Person'] }, + { '@id': '_:b1', '@type': ['Person'] } + ] + ], + ['node without @type', () => [{ '@id': '_:b1' }, 'Invalid key: Expected "@type" but received undefined']] + ]) + .when('colored', executeWhen) + .then('should match result', executeThen); +}); diff --git a/packages/api-graph/src/private/schemas/colorNode.spec.ts b/packages/api-graph/src/private/schemas/colorNode.spec.ts index 8a786e28bb..a678079b42 100644 --- a/packages/api-graph/src/private/schemas/colorNode.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.spec.ts @@ -4,63 +4,14 @@ import { scenario } from '@testduet/given-when-then'; import colorNode from './colorNode'; import type { FlatNodeObject } from './FlatNodeObject'; -scenario('expandArray', bdd => { - bdd - .given( - 'a JSON-LD object with @context, @id, and @type', - () => - ({ - '@context': 'http://schema.org/', - '@id': '_:b1', - '@type': 'Person', - name: 'Jane Doe', - jobTitle: 'Professor', - telephone: '(425) 123-4567', - url: 'http://www.janedoe.com' - }) satisfies FlatNodeObject - ) - .when('expanded', value => colorNode(value)) - .then('should wrap @type and property values in array', (_, actual) => { - expect(actual).toEqual({ - '@context': 'http://schema.org/', - '@id': '_:b1', - '@type': ['Person'], - name: ['Jane Doe'], - jobTitle: ['Professor'], - telephone: ['(425) 123-4567'], - url: ['http://www.janedoe.com'] - }); - }); - - bdd - .given( - 'a JSON-LD object without @context, @id, and @type', - () => - ({ - '@id': '_:b1', - name: 'Jane Doe', - jobTitle: 'Professor', - telephone: '(425) 123-4567', - url: 'http://www.janedoe.com' - }) satisfies FlatNodeObject - ) - .when('expanded', value => colorNode(value)) - .then('should wrap @type and property values in array', (_, actual) => { - expect(actual).toEqual({ - '@id': '_:b1', - name: ['Jane Doe'], - jobTitle: ['Professor'], - telephone: ['(425) 123-4567'], - url: ['http://www.janedoe.com'] - }); - }); - +scenario('colorNode corner cases', bdd => { bdd .given( 'a recipe JSON-LD object with some property values of array', () => ({ '@id': '_:b1', + '@type': ['Recipe'], name: 'Mojito', ingredient: [ '12 fresh mint leaves', @@ -77,6 +28,7 @@ scenario('expandArray', bdd => { .then('should wrap property values in array', (_, actual) => { expect(actual).toEqual({ '@id': '_:b1', + '@type': ['Recipe'], name: ['Mojito'], ingredient: [ '12 fresh mint leaves', @@ -90,37 +42,35 @@ scenario('expandArray', bdd => { }); }); - bdd - .given( - 'a JSON-LD object with @type of type array', - () => - ({ - '@id': '_:b1', - '@type': ['HowTo', 'Message'] - }) satisfies FlatNodeObject - ) - .when('expnaded', value => colorNode(value)) - .then('should return both types', (_, actual) => { - expect(actual['@type']).toEqual(['HowTo', 'Message']); - }); - bdd.given .oneOf([ [ 'a node with hasPart as an array of string', - () => [{ '@id': '_:b1', hasPart: ['Hello, World!'] }, 'element in hasPart must be NodeReference'] + () => [ + { '@id': '_:b1', '@type': ['Conversation'], hasPart: ['Hello, World!'] }, + 'element in hasPart must be NodeReference' + ] ], [ 'a node with hasPart as a string', - () => [{ '@id': '_:b1', hasPart: 'Hello, World!' }, 'element in hasPart must be NodeReference'] + () => [ + { '@id': '_:b1', '@type': ['Conversation'], hasPart: 'Hello, World!' }, + 'element in hasPart must be NodeReference' + ] ], [ 'a node with isPartOf as an array of string', - () => [{ '@id': '_:b1', isPartOf: ['Hello, World!'] }, 'element in isPartOf must be NodeReference'] + () => [ + { '@id': '_:b1', '@type': ['Conversation'], isPartOf: ['Hello, World!'] }, + 'element in isPartOf must be NodeReference' + ] ], [ 'a node with isPartOf a string', - () => [{ '@id': '_:b1', isPartOf: 'Hello, World!' }, 'element in isPartOf must be NodeReference'] + () => [ + { '@id': '_:b1', '@type': ['Conversation'], isPartOf: 'Hello, World!' }, + 'element in isPartOf must be NodeReference' + ] ] ]) .when('colored', ([node]) => { @@ -138,40 +88,22 @@ scenario('expandArray', bdd => { }).toThrow(expectedMessage); }); - bdd.given - .oneOf([ - ['a node with a property value of null', () => ({ '@id': '_:b1', value: null })], - ['a node with a property value of empty array', () => ({ '@id': '_:b1', value: [] })] - ]) - .when('colored', node => colorNode(node)) - .then('the property should be removed', (_, slantNode) => { - expect(slantNode).toEqual({ '@id': '_:b1' }); - }); - - bdd.given - .oneOf([ - ['a node with hasPart of empty array', () => ({ '@id': '_:b1', hasPart: [] })], - ['a node with isPartOf of empty array', () => ({ '@id': '_:b1', isPartOf: [] })] - ]) - .when('colored', node => colorNode(node)) - .then('the property should be removed', (_, slantNode) => { - expect(slantNode).toEqual({ '@id': '_:b1' }); - }); - bdd .given( 'a node with a property value mixed with literal and node reference', () => ({ '@id': '_:b1', - value: ['Hello, World!', { '@id': '_:b2' }, 0, false] + '@type': ['Message'], + text: ['Hello, World!', { '@id': '_:b2' }, 0, false] }) satisfies FlatNodeObject ) .when('colored', node => colorNode(node)) .then('should convert', (_, slantNode) => { expect(slantNode).toEqual({ '@id': '_:b1', - value: ['Hello, World!', { '@id': '_:b2' }, 0, false] + '@type': ['Message'], + text: ['Hello, World!', { '@id': '_:b2' }, 0, false] }); }); }); diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 47c7edee0a..8c975a4455 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -31,12 +31,10 @@ function slantNode | undefine '@context': optional(string('@context must be a string')), '@id': identifier('@id is required and must be a string'), // Multi-membership is enabled by default. - '@type': optional( - pipe( - array(string('element in @type must be a string'), '@type must be array of string'), - freeze(), - minLength(1, '@type must have at least one element') - ) + '@type': pipe( + array(string('element in @type must be a string'), '@type must be array of string'), + freeze(), + minLength(1, '@type must have at least one element') ), // We follow Schema.org that "hasPart" denotes children. // This relationship is "membership" than "hierarchy". From 2a6e3d280dc5c287a77723560720ccaa4f0122dd Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:13:22 +0000 Subject: [PATCH 024/125] Clean up --- .../src/private/schemas/colorNode.opinion.spec.ts | 7 +++++-- packages/api-graph/src/private/schemas/colorNode.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts b/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts index 1e4659b86b..89b1f1aab4 100644 --- a/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts @@ -36,8 +36,11 @@ scenario('Must have `@id`: every node in the graph must be identifiable', bdd => { '@id': '_:b1', '@type': ['Person'] } ] ], - ['node with @id of empty string', () => [{ '@id': '' }, '@id is required and must be a string']], - ['node without @id', () => [{}, '@id is required and must be a string']] + [ + 'node with @id of empty string', + () => [{ '@id': '' }, '@id is required and must be an IRI or blank node identifier'] + ], + ['node without @id', () => [{}, '@id is required and must be an IRI or blank node identifier']] ]) .when('colored', executeWhen) .then('should match result', executeThen); diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 8c975a4455..9eeb6af27f 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -28,8 +28,8 @@ function slantNode | undefine objectWithRest( { // We treat @context as opaque string than a schema. - '@context': optional(string('@context must be a string')), - '@id': identifier('@id is required and must be a string'), + '@context': optional(string('@context must be an IRI')), + '@id': identifier('@id is required and must be an IRI or blank node identifier'), // Multi-membership is enabled by default. '@type': pipe( array(string('element in @type must be a string'), '@type must be array of string'), From 66cb6f46b73c2566aa68a0edd111f75b18d9e194 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:31:21 +0000 Subject: [PATCH 025/125] Better test result --- .../api-graph/src/private/schemas/colorNode.opinion.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts b/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts index 89b1f1aab4..84dcf6bb63 100644 --- a/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts +++ b/packages/api-graph/src/private/schemas/colorNode.opinion.spec.ts @@ -15,14 +15,14 @@ function executeWhen([node]: [unknown, string | SlantNode]): [unknown] | [undefi } function executeThen([_, errorOrResult]: [unknown, string | SlantNode], result: [unknown] | [undefined, SlantNode]) { - if (typeof errorOrResult === 'string') { + if (typeof result[0] === 'undefined') { + expect(result[1]).toEqual(errorOrResult); + } else { expect(() => { if (result[0]) { throw result[0]; } }).toThrow(errorOrResult); - } else { - expect(result[1]).toEqual(errorOrResult); } } From 84906e46431f1eb571fdac3a6f72500dd2f266e8 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:46:18 +0000 Subject: [PATCH 026/125] Add slantNodeWithFix schema --- .../src/private/schemas/colorNode.ts | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 9eeb6af27f..cf478401a6 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -1,5 +1,6 @@ import { array, + looseObject, minLength, null_, objectWithRest, @@ -7,6 +8,7 @@ import { parse, pipe, string, + transform, union, type ErrorMessage, type InferOutput, @@ -72,6 +74,49 @@ type SlantNode = InferOutput['entries' [key: string]: unknown; }; +function slantNodeWithFix() { + return pipe( + looseObject({}), + transform(node => { + const propertyMap = new Map(); + let context: string | undefined; + let id: string | undefined; + + for (const [key, value] of Object.entries(node)) { + const parsedValue = parse( + union([array(union([literal(), nodeReference()])), nodeReference(), literal(), null_()]), + value + ); + + switch (key) { + case '@context': + context = parse(string(), value); + break; + + case '@id': + id = parse(string(), value); + break; + + default: { + const slantedValue = Array.isArray(parsedValue) + ? parsedValue + : Object.freeze(parsedValue === null ? [] : [parsedValue]); + + slantedValue.length && propertyMap.set(key, slantedValue); + + break; + } + } + } + + return parse( + slantNode(), + Object.fromEntries([...(context ? [['@context', context]] : []), ['@id', id], ...Array.from(propertyMap)]) + ); + }) + ); +} + /** * Put our opinions into the node. * @@ -96,42 +141,8 @@ type SlantNode = InferOutput['entries' * @returns An opinionated node object which conforms to JSON-LD specification. */ function colorNode(node: FlatNodeObject | SlantNode): SlantNode { - const propertyMap = new Map(); - let context: string | undefined; - let id: string | undefined; - - for (const [key, value] of Object.entries(node)) { - const parsedValue = parse( - union([array(union([literal(), nodeReference()])), nodeReference(), literal(), null_()]), - value - ); - - switch (key) { - case '@context': - context = parse(string(), value); - break; - - case '@id': - id = parse(string(), value); - break; - - default: { - const slantedValue = Array.isArray(parsedValue) - ? parsedValue - : Object.freeze(parsedValue === null ? [] : [parsedValue]); - - slantedValue.length && propertyMap.set(key, slantedValue); - - break; - } - } - } - - return parse( - slantNode(), - Object.fromEntries([...(context ? [['@context', context]] : []), ['@id', id], ...Array.from(propertyMap)]) - ); + return parse(slantNodeWithFix(), node); } export default colorNode; -export { slantNode, type SlantNode }; +export { slantNode, slantNodeWithFix, type SlantNode }; From 7b8d2dbe41f89e672135bb5f9d1276317fb483ec Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:52:21 +0000 Subject: [PATCH 027/125] Simplify --- packages/api-graph/src/private/Graph.ts | 37 +++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 0a751522ec..733b463259 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -158,9 +158,14 @@ class Graph extends EventTarget { return Object.freeze(affectedIds); } - // eslint-disable-next-line complexity + /** + * Upserts a list of nodes in a transaction. Will trigger observers once upsert has completed. + * + * @param nodes Nodes to upsert. + */ upsert(...nodes: readonly SlantNode[]) { const affectedIdSet: Set = new Set(); + const markIdAsAffected = affectedIdSet.add.bind(affectedIdSet); for (const node of nodes) { const id = node['@id']; @@ -174,20 +179,22 @@ class Graph extends EventTarget { const existingNode = this.#graph.get(id); if (existingNode) { - for (const existingChildId of nodeReferenceListToIdentifierSet(existingNode.hasPart || []).difference( + const removedHasPartIdSet = nodeReferenceListToIdentifierSet(existingNode.hasPart || []).difference( nodeReferenceListToIdentifierSet(node.hasPart || []) - )) { - for (const id of this.#setEdge(existingNode['@id'], 'hasPart', existingChildId, 'delete')) { - affectedIdSet.add(id); - } + ); + + for (const removedHasPartId of removedHasPartIdSet) { + this.#setEdge(existingNode['@id'], 'hasPart', removedHasPartId, 'delete').values().forEach(markIdAsAffected); } - for (const existingParentId of nodeReferenceListToIdentifierSet(existingNode.isPartOf || []).difference( + const removedIsPartOfIdSet = nodeReferenceListToIdentifierSet(existingNode.isPartOf || []).difference( nodeReferenceListToIdentifierSet(node.isPartOf || []) - )) { - for (const id of this.#setEdge(existingNode['@id'], 'isPartOf', existingParentId, 'delete')) { - affectedIdSet.add(id); - } + ); + + for (const removedIsPartOfId of removedIsPartOfIdSet) { + this.#setEdge(existingNode['@id'], 'isPartOf', removedIsPartOfId, 'delete') + .values() + .forEach(markIdAsAffected); } } @@ -198,15 +205,11 @@ class Graph extends EventTarget { const nodeId = node['@id']; for (const { '@id': childId } of node.hasPart || []) { - for (const id of this.#setEdge(nodeId, 'hasPart', childId, 'add')) { - affectedIdSet.add(id); - } + this.#setEdge(nodeId, 'hasPart', childId, 'add').values().forEach(markIdAsAffected); } for (const { '@id': parentId } of node.isPartOf || []) { - for (const id of this.#setEdge(nodeId, 'isPartOf', parentId, 'add')) { - affectedIdSet.add(id); - } + this.#setEdge(nodeId, 'isPartOf', parentId, 'add').values().forEach(markIdAsAffected); } } From c6b46471353e23cfd297407171eec9cd389e4ce0 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:56:40 +0000 Subject: [PATCH 028/125] Clean up --- packages/api-graph/src/private/Graph.ts | 52 ++++++++++++++----------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 733b463259..4cc117c470 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -65,7 +65,8 @@ class Graph extends EventTarget { return new Map(this.#graph); } - #setEdge( + // eslint-disable-next-line complexity + #setTriplet( subjectId: Identifier, linkType: 'hasPart' | 'isPartOf', objectId: Identifier, @@ -102,9 +103,7 @@ class Graph extends EventTarget { objectIsPartOf.add(subjectId); nextObject = { ...object, isPartOf: identifierSetToNodeReferenceList(objectIsPartOf) }; } - } else { - operation satisfies 'delete'; - + } else if (operation === 'delete') { if (subjectHasPart.has(objectId)) { subjectHasPart.delete(objectId); nextSubject = { ...subject, hasPart: identifierSetToNodeReferenceList(subjectHasPart) }; @@ -114,10 +113,10 @@ class Graph extends EventTarget { objectIsPartOf.delete(subjectId); nextObject = { ...object, isPartOf: identifierSetToNodeReferenceList(objectIsPartOf) }; } + } else { + operation satisfies never; } - } else { - linkType satisfies 'isPartOf'; - + } else if (linkType === 'isPartOf') { if (operation === 'add') { if (!subjectIsPartOf.has(objectId)) { subjectIsPartOf.add(objectId); @@ -128,9 +127,7 @@ class Graph extends EventTarget { objectHasPart.add(subjectId); nextObject = { ...object, hasPart: identifierSetToNodeReferenceList(objectHasPart) }; } - } else { - operation satisfies 'delete'; - + } else if (operation === 'delete') { if (subjectIsPartOf.has(objectId)) { subjectIsPartOf.delete(objectId); nextSubject = { ...subject, isPartOf: identifierSetToNodeReferenceList(subjectIsPartOf) }; @@ -140,7 +137,11 @@ class Graph extends EventTarget { objectHasPart.delete(subjectId); nextObject = { ...object, hasPart: identifierSetToNodeReferenceList(objectHasPart) }; } + } else { + operation satisfies never; } + } else { + linkType satisfies never; } const affectedIds = new Set(); @@ -178,21 +179,24 @@ class Graph extends EventTarget { const existingNode = this.#graph.get(id); + // Remove hasPart/isPartOf if the existing node does not match the upserted node. if (existingNode) { - const removedHasPartIdSet = nodeReferenceListToIdentifierSet(existingNode.hasPart || []).difference( - nodeReferenceListToIdentifierSet(node.hasPart || []) + const removedHasPartIdSet = nodeReferenceListToIdentifierSet(existingNode.hasPart ?? []).difference( + nodeReferenceListToIdentifierSet(node.hasPart ?? []) ); for (const removedHasPartId of removedHasPartIdSet) { - this.#setEdge(existingNode['@id'], 'hasPart', removedHasPartId, 'delete').values().forEach(markIdAsAffected); + this.#setTriplet(existingNode['@id'], 'hasPart', removedHasPartId, 'delete') + .values() + .forEach(markIdAsAffected); } - const removedIsPartOfIdSet = nodeReferenceListToIdentifierSet(existingNode.isPartOf || []).difference( - nodeReferenceListToIdentifierSet(node.isPartOf || []) + const removedIsPartOfIdSet = nodeReferenceListToIdentifierSet(existingNode.isPartOf ?? []).difference( + nodeReferenceListToIdentifierSet(node.isPartOf ?? []) ); for (const removedIsPartOfId of removedIsPartOfIdSet) { - this.#setEdge(existingNode['@id'], 'isPartOf', removedIsPartOfId, 'delete') + this.#setTriplet(existingNode['@id'], 'isPartOf', removedIsPartOfId, 'delete') .values() .forEach(markIdAsAffected); } @@ -201,28 +205,32 @@ class Graph extends EventTarget { this.#setGraphNode(node); } + // Add hasPart/isPartOf. for (const node of nodes) { const nodeId = node['@id']; for (const { '@id': childId } of node.hasPart || []) { - this.#setEdge(nodeId, 'hasPart', childId, 'add').values().forEach(markIdAsAffected); + this.#setTriplet(nodeId, 'hasPart', childId, 'add').values().forEach(markIdAsAffected); } for (const { '@id': parentId } of node.isPartOf || []) { - this.#setEdge(nodeId, 'isPartOf', parentId, 'add').values().forEach(markIdAsAffected); + this.#setTriplet(nodeId, 'isPartOf', parentId, 'add').values().forEach(markIdAsAffected); } } - const affectedIds = Object.freeze(Array.from(affectedIdSet.values())); - - for (const id of affectedIds) { + // ASSERT: Make sure all affected ids are in the graph. + for (const id of affectedIdSet) { if (!this.#graph.has(id)) { throw new Error(`ASSERTION: Cannot find affected node with @id of "${id}"`); } } + const changeEvent = Object.freeze({ + ids: Object.freeze(Array.from(affectedIdSet.values())) + }); + for (const controller of this.#observerControllerSet) { - controller.enqueue(Object.freeze({ ids: affectedIds })); + controller.enqueue(changeEvent); } } } From 0297d58b2fd4ac9439bdc3477cb009395124a806 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:58:54 +0000 Subject: [PATCH 029/125] Clean up comment --- packages/api-graph/src/private/schemas/colorNode.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index cf478401a6..3386415443 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -125,14 +125,13 @@ function slantNodeWithFix() { * - Simplifying downstream logics * - Must have `@id`: every node in the graph must be identifiable * - Uniform getter/setter: every property value is an array, except `@context` and `@id` - * - Uniform typing: node reference must be `{ "@id": string }` to reduce confusion with a plain string + * - Uniform typing: node reference must be `{ "@id": string }` to reduce confusion with plain string * - Support multiple types: every `@type` must be an array of string * - Reduce confusion: empty array and `null` is removed * - `[]` and `null` are same as if the property is removed * - Flattened: property values must be non-null literals or node reference, no nested objects - * - `null` will be converted to [] and eventually the property will be removed * - Any array containing `null` is not supported and will throw, as it is likely a bug in code - * - Do not handle full JSON-LD spec: `@context` is an opaque string and the schema is not honored + * - Do not handle full JSON-LD spec: `@context` is an opaque string and its schema is not honored * - Auto-linking for Schema.org: `hasPart` and `isPartOf` are auto-inversed * - Keep its root: every node is compliant to JSON-LD, understood by standard parsers * - Debuggability: must have at least one `@type` From 0cde4b28e715ccc9b490d9e5aaa298fa37acf4bc Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 07:59:28 +0000 Subject: [PATCH 030/125] Reorder --- packages/api-graph/src/private/Graph.ts | 44 ++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 4cc117c470..f97faf4da0 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -43,28 +43,6 @@ class Graph extends EventTarget { this.#graph.set(safeNode['@id'], safeNode); } - observe(): AsyncIterator { - const observerControllerSet = this.#observerControllerSet; - let thisController: ReadableStreamDefaultController; - - const stream = new ReadableStream({ - cancel() { - // Iterator.cancel() will call cancel(). - observerControllerSet.delete(thisController); - }, - start(controller) { - thisController = controller; - observerControllerSet.add(controller); - } - }); - - return stream.values(); - } - - snapshot() { - return new Map(this.#graph); - } - // eslint-disable-next-line complexity #setTriplet( subjectId: Identifier, @@ -159,6 +137,28 @@ class Graph extends EventTarget { return Object.freeze(affectedIds); } + observe(): AsyncIterator { + const observerControllerSet = this.#observerControllerSet; + let thisController: ReadableStreamDefaultController; + + const stream = new ReadableStream({ + cancel() { + // Iterator.cancel() will call cancel(). + observerControllerSet.delete(thisController); + }, + start(controller) { + thisController = controller; + observerControllerSet.add(controller); + } + }); + + return stream.values(); + } + + snapshot() { + return new Map(this.#graph); + } + /** * Upserts a list of nodes in a transaction. Will trigger observers once upsert has completed. * From f2c41c06b4bad4442fe171eadfef5f745ec26fa7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 08:01:25 +0000 Subject: [PATCH 031/125] Clean up --- packages/api-graph/src/private/Graph.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index f97faf4da0..47dc5c945d 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -155,8 +155,11 @@ class Graph extends EventTarget { return stream.values(); } - snapshot() { - return new Map(this.#graph); + /** + * Takes a snapshot of the current graph. + */ + snapshot(): ReadonlyMap { + return Object.freeze(new Map(this.#graph)); } /** From fdffab29bfb83cf6cda74dade12729d327db693a Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 08:22:25 +0000 Subject: [PATCH 032/125] Fix comment --- packages/api-graph/src/private/Graph.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-graph/src/private/Graph.ts b/packages/api-graph/src/private/Graph.ts index 47dc5c945d..d22c7ea1e8 100644 --- a/packages/api-graph/src/private/Graph.ts +++ b/packages/api-graph/src/private/Graph.ts @@ -143,7 +143,7 @@ class Graph extends EventTarget { const stream = new ReadableStream({ cancel() { - // Iterator.cancel() will call cancel(). + // Iterator.return() will call cancel(). observerControllerSet.delete(thisController); }, start(controller) { From 05a81023e06a6a00349216ede253b6d26f26096e Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 08:23:56 +0000 Subject: [PATCH 033/125] Fix comment --- packages/api-graph/src/private/schemas/colorNode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index 3386415443..ef260b3b91 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -125,7 +125,7 @@ function slantNodeWithFix() { * - Simplifying downstream logics * - Must have `@id`: every node in the graph must be identifiable * - Uniform getter/setter: every property value is an array, except `@context` and `@id` - * - Uniform typing: node reference must be `{ "@id": string }` to reduce confusion with plain string + * - Unique typing: node reference must be `{ "@id": string }` to reduce confusion with plain string * - Support multiple types: every `@type` must be an array of string * - Reduce confusion: empty array and `null` is removed * - `[]` and `null` are same as if the property is removed From f9e5691cd8ebf65a4621a4a66e36af335da31dd7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 29 Oct 2025 08:26:57 +0000 Subject: [PATCH 034/125] Fix comment --- packages/api-graph/src/private/schemas/colorNode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-graph/src/private/schemas/colorNode.ts b/packages/api-graph/src/private/schemas/colorNode.ts index ef260b3b91..f46032aece 100644 --- a/packages/api-graph/src/private/schemas/colorNode.ts +++ b/packages/api-graph/src/private/schemas/colorNode.ts @@ -127,7 +127,7 @@ function slantNodeWithFix() { * - Uniform getter/setter: every property value is an array, except `@context` and `@id` * - Unique typing: node reference must be `{ "@id": string }` to reduce confusion with plain string * - Support multiple types: every `@type` must be an array of string - * - Reduce confusion: empty array and `null` is removed + * - Reduce confusion: property value with empty array and `null` is removed * - `[]` and `null` are same as if the property is removed * - Flattened: property values must be non-null literals or node reference, no nested objects * - Any array containing `null` is not supported and will throw, as it is likely a bug in code From 4b17bab656b3611960168e68c46742561d2cb738 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 4 Nov 2025 03:57:06 +0000 Subject: [PATCH 035/125] First working graph --- __tests__/html/renderActivity.profiling.html | 3 +- package-lock.json | 46 +++- package.json | 4 + packages/api-graph/package.json | 11 +- packages/api-graph/src/index.ts | 4 +- .../api-graph/src/private/GraphContext.ts | 20 +- .../api-graph/src/private/GraphProvider.tsx | 239 +++++++++++++++--- .../api-graph/src/private/schemas/isOfType.ts | 7 - .../api-graph/src/private/useNodeObject.ts | 16 +- .../src/private/useOrderedActivities.ts | 6 + packages/api/src/boot/graph.ts | 2 +- packages/api/src/hooks/Composer.tsx | 13 +- packages/api/src/hooks/useActivities.ts | 11 +- packages/core-graph/.eslintrc.yml | 2 + packages/core-graph/.gitignore | 4 + packages/core-graph/README.md | 0 packages/core-graph/package.json | 64 +++++ packages/core-graph/src/index.ts | 6 + .../src/private/Graph.isPartOf.spec.ts | 0 .../src/private/Graph.spec.ts | 0 .../src/private/Graph.ts | 40 ++- .../private/schemas/BlankNodeIdentifier.ts | 0 .../src/private/schemas/FlatNodeObject.ts | 2 +- .../src/private/schemas/Identifier.ts | 0 .../src/private/schemas/Literal.ts | 0 .../src/private/schemas/NodeReference.ts | 0 .../private/schemas/colorNode.opinion.spec.ts | 0 .../src/private/schemas/colorNode.spec.ts | 0 .../src/private/schemas/colorNode.ts | 2 +- .../private/schemas/expectExtendValibot.ts | 0 .../flattenNodeObject.conversation.spec.ts | 0 .../private/schemas/flattenNodeObject.spec.ts | 0 .../src/private/schemas/flattenNodeObject.ts | 10 +- .../src/private/schemas/identifier.spec.ts | 0 .../src/private/schemas/isOfType.spec.ts | 0 .../src/private/schemas/isOfType.ts | 5 + .../src/private/schemas/messageNode.ts | 48 ++++ .../src/private/schemas/private/freeze.ts | 0 packages/core-graph/src/tsconfig.json | 3 + packages/core-graph/tsup.config.ts | 23 ++ packages/core/graph.js | 3 + packages/core/package.json | 12 + .../core/src/graph/createGraphFromStore.ts | 110 ++++++++ packages/core/src/graph/index.ts | 11 + packages/core/tsup.config.ts | 1 + 45 files changed, 657 insertions(+), 71 deletions(-) delete mode 100644 packages/api-graph/src/private/schemas/isOfType.ts create mode 100644 packages/api-graph/src/private/useOrderedActivities.ts create mode 100644 packages/core-graph/.eslintrc.yml create mode 100644 packages/core-graph/.gitignore create mode 100644 packages/core-graph/README.md create mode 100644 packages/core-graph/package.json create mode 100644 packages/core-graph/src/index.ts rename packages/{api-graph => core-graph}/src/private/Graph.isPartOf.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/Graph.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/Graph.ts (88%) rename packages/{api-graph => core-graph}/src/private/schemas/BlankNodeIdentifier.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/FlatNodeObject.ts (94%) rename packages/{api-graph => core-graph}/src/private/schemas/Identifier.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/Literal.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/NodeReference.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/colorNode.opinion.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/colorNode.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/colorNode.ts (97%) rename packages/{api-graph => core-graph}/src/private/schemas/expectExtendValibot.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/flattenNodeObject.conversation.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/flattenNodeObject.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/flattenNodeObject.ts (96%) rename packages/{api-graph => core-graph}/src/private/schemas/identifier.spec.ts (100%) rename packages/{api-graph => core-graph}/src/private/schemas/isOfType.spec.ts (100%) create mode 100644 packages/core-graph/src/private/schemas/isOfType.ts create mode 100644 packages/core-graph/src/private/schemas/messageNode.ts rename packages/{api-graph => core-graph}/src/private/schemas/private/freeze.ts (100%) create mode 100644 packages/core-graph/src/tsconfig.json create mode 100644 packages/core-graph/tsup.config.ts create mode 100644 packages/core/graph.js create mode 100644 packages/core/src/graph/createGraphFromStore.ts create mode 100644 packages/core/src/graph/index.ts diff --git a/__tests__/html/renderActivity.profiling.html b/__tests__/html/renderActivity.profiling.html index 7163153dc6..663d2d8d15 100644 --- a/__tests__/html/renderActivity.profiling.html +++ b/__tests__/html/renderActivity.profiling.html @@ -11,7 +11,8 @@ > - + +