diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d997a0950..17a60cf65b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Cleaned up `` and various CSS related code, in PR [#5611](https://github.com/microsoft/BotFramework-WebChat/pull/5611), by [@compulim](https://github.com/compulim) - (Experimental) Reworked the copilot variant to align with the modern Copilot UX, in PR [#5630](https://github.com/microsoft/BotFramework-WebChat/pull/5630), by [@OEvgeny](https://github.com/OEvgeny) - The legacy design is temporarily available as `copilot-deprecated` for migration +- New JSON-LD graph backend, by [@compulim](https://github.com/compulim) in PR [#5622](https://github.com/microsoft/BotFramework-WebChat/pull/5622) ### Changed diff --git a/__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html b/__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html new file mode 100644 index 0000000000..93dce06f4d --- /dev/null +++ b/__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html @@ -0,0 +1,87 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html.snap-1.png b/__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html.snap-1.png new file mode 100644 index 0000000000..c7515061ea Binary files /dev/null and b/__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html.snap-1.png differ diff --git a/__tests__/html2/activityOrdering/twoOutgoingMessage.html b/__tests__/html2/activityOrdering/twoOutgoingMessage.html new file mode 100644 index 0000000000..79b46392fe --- /dev/null +++ b/__tests__/html2/activityOrdering/twoOutgoingMessage.html @@ -0,0 +1,50 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/basic/remount.html b/__tests__/html2/basic/remount.html index 68abb41fcb..ebf369c1c6 100644 --- a/__tests__/html2/basic/remount.html +++ b/__tests__/html2/basic/remount.html @@ -26,6 +26,7 @@ () => createDirectLineWithTranscript([ { + id: 'a-00001', // ID is needed to make sure activity is not added twice when remounted and resubscribed. text: `Hello, World!`, type: 'message', timestamp: '2000-01-23T12:34:56.12345Z' diff --git a/__tests__/html2/chatAdapter/sequenceId.directLine.outgoing.html b/__tests__/html2/chatAdapter/sequenceId.directLine.outgoing.html index 4be1a4f4fc..b1583b0269 100644 --- a/__tests__/html2/chatAdapter/sequenceId.directLine.outgoing.html +++ b/__tests__/html2/chatAdapter/sequenceId.directLine.outgoing.html @@ -78,6 +78,8 @@ await pageObjects.sendMessageViaSendBox('User activity has timestamp of 1.', { waitForSend: false }); await pageObjects.sendMessageViaSendBox('User activity has timestamp of 0.', { waitForSend: false }); + await pageConditions.numActivitiesShown(3); + const { activities } = store.getState(); // THEN: The first outgoing message should be the second, after the bot's message. @@ -86,14 +88,16 @@ // THEN: The second outgoing message should be the third. expect(activities[2].text).toBe('User activity has timestamp of 0.'); - // THEN: The first outgoing message should have a smaller sequence ID than the bot's message. + // THEN: The first outgoing message should have a smaller position than the bot's message. expect( - activities[0].channelData['webchat:sequence-id'] < activities[1].channelData['webchat:sequence-id'] + activities[0].channelData['webchat:internal:position'] < + activities[1].channelData['webchat:internal:position'] ).toBe(true); - // THEN: The first outgoing message should have a smaller sequence ID than the second outgoing message. + // THEN: The first outgoing message should have a smaller position than the second outgoing message. expect( - activities[1].channelData['webchat:sequence-id'] < activities[2].channelData['webchat:sequence-id'] + activities[1].channelData['webchat:internal:position'] < + activities[2].channelData['webchat:internal:position'] ).toBe(true); // THEN: Both outgoing messages should not send "webchat:sequence-id" and "state". diff --git a/__tests__/html2/part-grouping/position.html b/__tests__/html2/part-grouping/position.html index 722395beb8..a164e4c0f1 100644 --- a/__tests__/html2/part-grouping/position.html +++ b/__tests__/html2/part-grouping/position.html @@ -278,4 +278,4 @@ - \ No newline at end of file + diff --git a/__tests__/html2/preact/activity/feedback.status.html b/__tests__/html2/preact/activity/feedback.status.html index c6f20b56e2..180c61f8c4 100644 --- a/__tests__/html2/preact/activity/feedback.status.html +++ b/__tests__/html2/preact/activity/feedback.status.html @@ -136,6 +136,7 @@ ); await pageConditions.uiConnected(); + await pageConditions.numActivitiesShown(3); await host.snapshot('local'); diff --git a/__tests__/setup/setupCryptoRandomUUID.js b/__tests__/setup/setupCryptoRandomUUID.js new file mode 100644 index 0000000000..db2f6dc178 --- /dev/null +++ b/__tests__/setup/setupCryptoRandomUUID.js @@ -0,0 +1,11 @@ +import { v4 } from 'uuid'; + +// In browser, only works in secure context. +if (!global.crypto?.randomUUID) { + global.crypto = { + ...global.crypto, + randomUUID() { + return v4(); + } + }; +} diff --git a/jest.legacy.config.js b/jest.legacy.config.js index f6cd01710c..a4dbde0518 100644 --- a/jest.legacy.config.js +++ b/jest.legacy.config.js @@ -63,14 +63,17 @@ module.exports = { '/__tests__/setup/setupGlobalAgent.js', '/__tests__/setup/preSetupTestFramework.js', '/__tests__/setup/setupCryptoGetRandomValues.js', + '/__tests__/setup/setupCryptoRandomUUID.js', '/__tests__/setup/setupImageSnapshot.js', '/__tests__/setup/setupTestNightly.js', '/__tests__/setup/setupTimeout.js' ], testMatch: ['**/__tests__/**/*.?([mc])[jt]s?(x)', '**/?(*.)+(spec|test).?([mc])[jt]s?(x)'], testPathIgnorePatterns: [ + '/dist/', '/lib/', '/node_modules/', + '/static/', '/__tests__/html/.*?(\\.html)', '/__tests__/html/__dist__', '/__tests__/html/__jest__', 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..b081f77d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@msinternal/botframework-webchat-root", - "version": "9.9.9", + "version": "4.18.1-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@msinternal/botframework-webchat-root", - "version": "9.9.9", + "version": "4.18.1-0", "license": "MIT", "workspaces": [ "./packages/tsconfig", @@ -14,6 +14,7 @@ "./packages/test/dev-server", "./packages/test/harness", "./packages/test/web-server", + "./packages/core-graph", "./packages/core", "./packages/react-hooks", "./packages/react-valibot", @@ -21,6 +22,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 +3035,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 @@ -3049,6 +3055,10 @@ "resolved": "packages/support/cldr-data-downloader", "link": true }, + "node_modules/@msinternal/botframework-webchat-core-graph": { + "resolved": "packages/core-graph", + "link": true + }, "node_modules/@msinternal/botframework-webchat-debug-theme": { "resolved": "packages/debug-theme", "link": true @@ -3457,6 +3467,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, @@ -3925,6 +3944,12 @@ "version": "3.0.3", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", + "dev": true + }, "node_modules/@types/uuid": { "version": "10.0.0", "dev": true, @@ -6367,6 +6392,8 @@ }, "node_modules/core-js-pure": { "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.44.0.tgz", + "integrity": "sha512-gvMQAGB4dfVUxpYD0k3Fq8J+n5bB6Ytl15lqlZrOIXFzxOhtPaObfkQGHtMRdyjIf7z2IeNULwi1jEwyS+ltKQ==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -8982,7 +9009,8 @@ }, "node_modules/handler-chain": { "version": "0.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/handler-chain/-/handler-chain-0.1.0.tgz", + "integrity": "sha512-Bp0Imbm0pmt3FUNDK1y4Fj31K/tOGfVHKNZGprcuedHrcoRNwC7EYtJwJfdl1x9q8Lcs21sK43hbB8V01f+grA==", "dependencies": { "handler-chain": "^0.1.0" } @@ -10204,6 +10232,8 @@ }, "node_modules/iter-fest": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/iter-fest/-/iter-fest-0.3.0.tgz", + "integrity": "sha512-t76cnHCl9MEZRaUb0VBwTXP8dWjxbcTYm91VGWfQYjSMXKkjdOAIjUsaMYuLIy9jxk9fT55XIlb4Y/HACK/zlw==", "license": "MIT", "dependencies": { "iter-fest": "^0.3.0" @@ -17894,7 +17924,8 @@ }, "node_modules/use-ref-from": { "version": "0.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/use-ref-from/-/use-ref-from-0.1.0.tgz", + "integrity": "sha512-PRjmfhUGUKghhOjKV1dBU66M7CASdb4NkMsaaWLdJA81yOZFlVL7Pi3O9aD+68pRh0VrRQjZfS6Ux3vPy1VhRg==", "dependencies": { "@babel/runtime-corejs3": "^7.24.1", "use-ref-from": "^0.1.0" @@ -17926,7 +17957,8 @@ }, "node_modules/uuid": { "version": "8.3.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "bin": { "uuid": "dist/bin/uuid" } @@ -17946,7 +17978,8 @@ }, "node_modules/valibot": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", "peerDependencies": { "typescript": ">=5" }, @@ -18655,6 +18688,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 +18715,41 @@ "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": { + "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", + "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", + "@types/use-sync-external-store": "^1.5.0", + "botframework-webchat-core": "0.0.0-0", + "type-fest": "^4.41.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "handler-chain": "0.1.0", + "react": ">= 16.8.6", + "use-ref-from": "0.1.0" + } + }, + "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", @@ -19039,6 +19108,9 @@ "cross-env": "^10.0.0", "type-fest": "^4.41.0", "typescript": "~5.8.3" + }, + "peerDependencies": { + "valibot": "1.1.0" } }, "packages/base/node_modules/type-fest": { @@ -19578,12 +19650,15 @@ "dependencies": { "@babel/runtime": "7.28.2", "@redux-devtools/extension": "3.3.0", + "core-js-pure": "3.44.0", + "iter-fest": "0.3.0", "jwt-decode": "4.0.0", "math-random": "2.0.1", "mime": "4.0.7", "redux": "5.0.1", "redux-saga": "1.3.0", "simple-update-in": "2.2.0", + "uuid": "8.3.2", "valibot": "1.1.0" }, "devDependencies": { @@ -19592,6 +19667,7 @@ "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-env": "^7.28.0", "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-core-graph": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", "@types/jest": "^30.0.0", "@types/node": "^24.1.0", @@ -19607,6 +19683,44 @@ "node": ">=12.0.0" } }, + "packages/core-graph": { + "name": "@msinternal/botframework-webchat-core-graph", + "version": "0.0.0-0", + "license": "MIT", + "dependencies": { + "valibot": "1.1.0" + }, + "devDependencies": { + "@msinternal/botframework-webchat-base": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", + "@types/uuid": "^8.3.4", + "type-fest": "^4.41.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "core-js-pure": "3.44.0", + "iter-fest": "0.3.0", + "uuid": "8.3.2" + } + }, + "packages/core-graph/node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, + "packages/core-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/core/node_modules/@jest/expect-utils": { "version": "30.0.5", "dev": true, diff --git a/package.json b/package.json index d80e4983b8..10e722be91 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "./packages/test/dev-server", "./packages/test/harness", "./packages/test/web-server", + "./packages/core-graph", "./packages/core", "./packages/react-hooks", "./packages/react-valibot", @@ -27,6 +28,7 @@ "./packages/styles", "./packages/support/cldr-data-downloader", "./packages/support/cldr-data", + "./packages/api-graph", "./packages/api-middleware", "./packages/api", "./packages/isomorphic-react", @@ -72,11 +74,13 @@ "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", "precommit:eslint:component": "cd packages && cd component && npm run precommit:eslint", "precommit:eslint:core": "cd packages && cd core && npm run precommit:eslint", + "precommit:eslint:core-graph": "cd packages && cd core-graph && npm run precommit:eslint", "precommit:eslint:debug-theme": "cd packages && cd debug-theme && npm run precommit:eslint", "precommit:eslint:directlinespeech": "cd packages && cd directlinespeech && npm run precommit:eslint", "precommit:eslint:fluent-theme": "cd packages && cd fluent-theme && npm run precommit:eslint", @@ -106,11 +110,13 @@ "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", "precommit:typecheck:component": "cd packages && cd component && npm run precommit:typecheck", "precommit:typecheck:core": "cd packages && cd core && npm run precommit:typecheck", + "precommit:typecheck:core-graph": "cd packages && cd core-graph && npm run precommit:typecheck", "precommit:typecheck:debug-theme": "cd packages && cd debug-theme && npm run precommit:typecheck", "precommit:typecheck:fluent-theme": "cd packages && cd fluent-theme && npm run precommit:typecheck", "precommit:typecheck:react-hooks": "cd packages && cd react-hooks && npm run precommit:typecheck", @@ -132,11 +138,13 @@ "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", "start:component": "cd packages && cd component && npm start", "start:core": "cd packages && cd core && npm start", + "start:core-graph": "cd packages && cd core-graph && npm start", "start:debug-theme": "cd packages && cd debug-theme && npm start", "start:directlinespeech": "cd packages && cd directlinespeech && npm start", "start:fluent-theme": "cd packages && cd fluent-theme && 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..6d438df79c --- /dev/null +++ b/packages/api-graph/package.json @@ -0,0 +1,78 @@ +{ + "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\" \"../react-hooks/package.json\" \"../react-valibot/package.json\" \"../core/package.json\"" + }, + "pinDependencies": {}, + "localDependencies": { + "@msinternal/botframework-webchat-base": "development", + "@msinternal/botframework-webchat-react-hooks": "development", + "@msinternal/botframework-webchat-react-valibot": "development", + "botframework-webchat-core": "development" + }, + "devDependencies": { + "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", + "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", + "@types/use-sync-external-store": "^1.5.0", + "botframework-webchat-core": "0.0.0-0", + "type-fest": "^4.41.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "handler-chain": "0.1.0", + "react": ">= 16.8.6", + "use-ref-from": "0.1.0" + }, + "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..c4e27c862e --- /dev/null +++ b/packages/api-graph/src/index.ts @@ -0,0 +1,2 @@ +export { default as GraphProvider } from './private/GraphProvider'; +export { default as useOrderedActivities } from './private/useOrderedActivities'; diff --git a/packages/api-graph/src/private/GraphContext.ts b/packages/api-graph/src/private/GraphContext.ts new file mode 100644 index 0000000000..4817b20194 --- /dev/null +++ b/packages/api-graph/src/private/GraphContext.ts @@ -0,0 +1,33 @@ +import { freeze } from '@msinternal/botframework-webchat-base/valibot'; +import type { WebChatActivity } from 'botframework-webchat-core'; +import { createContext, useContext } from 'react'; +import { array, custom, object, pipe, tuple } from 'valibot'; + +const graphContextSchema = pipe( + object({ + orderedActivitiesState: pipe(tuple([pipe(array(custom(() => true)), freeze())]), freeze()) + }), + freeze() +); + +// Because SlantNode need some special treatment around objectWithRest(), InferOutput is not working here. +// type GraphContextType = InferOutput; + +type GraphContextType = { + readonly orderedActivitiesState: readonly [readonly WebChatActivity[]]; +}; + +const GraphContext = createContext( + new Proxy({} as GraphContextType, { + get() { + throw new Error('This hook can only be used under '); + } + }) +); + +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..fc8bb12357 --- /dev/null +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -0,0 +1,105 @@ +import type { DirectLineActivityNode } from '@msinternal/botframework-webchat-core-graph'; +import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { createStore, WebChatActivity } from 'botframework-webchat-core'; +import { createGraphFromStore, isOfType, type GraphSubscriber, type Identifier } from 'botframework-webchat-core/graph'; +import React, { memo, useEffect, useMemo, useState } from 'react'; +import { custom, function_, object, optional, parse, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import GraphContext, { graphContextSchema, GraphContextType } from './GraphContext'; + +const EMPTY_ARRAY = Object.freeze([]); + +const graphProviderPropsSchema = pipe( + object({ + children: optional(reactNode()), + store: custom>( + value => safeParse(object({ getState: function_(), subscribe: function_() }), value).success + ) + }), + readonly() +); + +type GraphProviderProps = InferInput; + +function GraphProvider(props: GraphProviderProps) { + const { children, store } = validateProps(graphProviderPropsSchema, props); + + const graph = useMemo(() => createGraphFromStore(store), [store]); + + const [orderedActivityNodes, setOrderedActivityNodes] = useState(EMPTY_ARRAY); + + useEffect(() => { + // Sync between graph and `orderedActivities`. + const handleChange: GraphSubscriber = record => { + const state = graph.getState(); + + setOrderedActivityNodes(prevOrderedMessages => { + let nextOrderedMessageMap: Map | undefined; + + for (const id of record.upsertedNodeIdentifiers) { + const node = state.get(id); + + if (node && isOfType(node, 'urn:microsoft:webchat:direct-line-activity')) { + const activityNode = node as DirectLineActivityNode; + + if (!nextOrderedMessageMap) { + nextOrderedMessageMap = new Map(prevOrderedMessages.map(node => [node['@id'], node])); + } + + const permanentId = activityNode['@id']; + + nextOrderedMessageMap.delete(permanentId); + nextOrderedMessageMap.set(permanentId, activityNode); + } + } + + if (nextOrderedMessageMap) { + // TODO: [P0] Insertion sort is cheaper by 20x if inserting 1 activity into a list of 1,000 activities. + return Object.freeze( + Array.from(nextOrderedMessageMap.values()).sort((x, y) => x.position[0] - y.position[0]) + ); + } + + return prevOrderedMessages; + }); + }; + + const unsubscribe = graph.subscribe(handleChange); + + // Triggers initial sync. + // Activities queued before Web Chat mounted should be synchronized. + handleChange({ upsertedNodeIdentifiers: new Set(graph.getState().keys()) }); + + return () => { + unsubscribe(); + setOrderedActivityNodes(EMPTY_ARRAY); + }; + }, [graph, setOrderedActivityNodes]); + + const orderedActivitiesState = useMemo( + () => + Object.freeze([ + Object.freeze( + orderedActivityNodes.map( + node => node['urn:microsoft:webchat:direct-line-activity:raw-json'][0]['@value'] as WebChatActivity + ) + ) + ] as const), + [orderedActivityNodes] + ); + + const context = useMemo( + () => + parse(graphContextSchema, { + orderedActivitiesState + }), + [orderedActivitiesState] + ); + + return {children}; +} + +GraphProvider.displayName = 'GraphProvider'; + +export default memo(GraphProvider); +export { graphProviderPropsSchema, type GraphProviderProps }; diff --git a/packages/api-graph/src/private/useOrderedActivities.ts b/packages/api-graph/src/private/useOrderedActivities.ts new file mode 100644 index 0000000000..d7bcd570ed --- /dev/null +++ b/packages/api-graph/src/private/useOrderedActivities.ts @@ -0,0 +1,6 @@ +import type { WebChatActivity } from 'botframework-webchat-core'; +import { useGraphContext } from './GraphContext'; + +export default function useOrderedActivities(): readonly [readonly WebChatActivity[]] { + return useGraphContext().orderedActivitiesState; +} 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..f72bfcaf65 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -93,7 +93,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../api-middleware/package.json\" \"../base/package.json\" \"../support/cldr-data/package.json\" \"../react-hooks/package.json\" \"../react-valibot/package.json\" \"../redux-store/package.json\" \"../tsconfig/package.json\" \"../core/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../api-graph/package.json\" \"../api-middleware/package.json\" \"../support/cldr-data/package.json\" \"../redux-store/package.json\"" }, "pinDependencies": { "@types/react": [ @@ -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..69c3288924 --- /dev/null +++ b/packages/api/src/boot/graph.ts @@ -0,0 +1 @@ +export { useOrderedActivities } from '@msinternal/botframework-webchat-api-graph'; diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index ce1de63ecc..abe6b47501 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -1,3 +1,4 @@ +import { GraphProvider } from '@msinternal/botframework-webchat-api-graph'; import { PolymiddlewareComposer, type Polymiddleware } from '@msinternal/botframework-webchat-api-middleware'; import { type LegacyActivityMiddleware, @@ -748,11 +749,13 @@ const ComposerWithStore = ({ onTelemetry, store, ...props }: ComposerWithStorePr return ( - - - - - + + + + + + + ); diff --git a/packages/api/src/hooks/internal/WebChatReduxContext.js b/packages/api/src/hooks/internal/WebChatReduxContext.js index d998b9a7be..639abb5a46 100644 --- a/packages/api/src/hooks/internal/WebChatReduxContext.js +++ b/packages/api/src/hooks/internal/WebChatReduxContext.js @@ -1,13 +1,14 @@ import { createContext } from 'react'; -import { createDispatchHook, createSelectorHook } from 'react-redux'; +import { createDispatchHook, createSelectorHook, createStoreHook } from 'react-redux'; const context = createContext(); const useDispatch = createDispatchHook(context); const useSelector = createSelectorHook(context); +const useStore = createStoreHook(context); context.displayName = 'WebChatReduxContext'; export default context; -export { useDispatch, useSelector }; +export { useDispatch, useSelector, useStore }; diff --git a/packages/api/src/hooks/useActivities.ts b/packages/api/src/hooks/useActivities.ts index 44237d7cb4..ea61e4fd21 100644 --- a/packages/api/src/hooks/useActivities.ts +++ b/packages/api/src/hooks/useActivities.ts @@ -1,7 +1,62 @@ +import { useOrderedActivities } from '@msinternal/botframework-webchat-api-graph'; import type { WebChatActivity } from 'botframework-webchat-core'; -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector, useStore } from './internal/WebChatReduxContext'; +import usePrevious from './internal/usePrevious'; -export default function useActivities(): [WebChatActivity[]] { - return [useSelector(({ activities }) => activities)]; +declare const process: { + env: { + NODE_ENV?: string | undefined; + }; +}; + +export default function useActivities(): readonly [readonly WebChatActivity[]] { + const activitiesFromGraphState = useOrderedActivities(); + + // Checks if store changed. + const store = useStore(); + const prevStore = usePrevious(store); + + // ASSERTION: Before we fully migrate to graph, make sure graph and Redux are the same. + if (process.env.NODE_ENV !== 'production') { + const [activitiesFromGraph] = activitiesFromGraphState; + + // Assert based on NODE_ENV. + // eslint-disable-next-line react-hooks/rules-of-hooks + const activitiesFromRedux = useSelector(({ activities }) => activities); + + // If store changed, skip one assertion turn. + // This is because is using `useState()` for propagating changes. + // It is always one render behind if `store` changed. + if (prevStore === store) { + if (activitiesFromGraph.length !== activitiesFromRedux.length) { + throw new Error( + `botframework-webchat-internal: Activities from graph and Redux are of different size (graph has ${activitiesFromGraph.length} activities, Redux has ${activitiesFromRedux.length} activities)`, + { + cause: { + activitiesFromGraph, + activitiesFromRedux + } + } + ); + } + + for (let index = 0; index < activitiesFromGraph.length; index++) { + if (!Object.is(activitiesFromGraph.at(index), activitiesFromRedux.at(index))) { + throw new Error( + `botframework-webchat-internal: Activities from graph and Redux are of different at index ${index}`, + { + cause: { + activitiesFromGraph, + activitiesFromRedux, + index + } + } + ); + } + } + } + } + + return activitiesFromGraphState; } diff --git a/packages/api/src/providers/ActivityListener/ActivityListenerComposer.tsx b/packages/api/src/providers/ActivityListener/ActivityListenerComposer.tsx index fc36762b68..12afdb7f28 100644 --- a/packages/api/src/providers/ActivityListener/ActivityListenerComposer.tsx +++ b/packages/api/src/providers/ActivityListener/ActivityListenerComposer.tsx @@ -8,7 +8,7 @@ type Props = Readonly<{ children?: ReactNode | undefined }>; const ActivityListenerComposer = memo(({ children }: Props) => { const [activities] = useActivities(); - const prevActivities = usePrevious(activities, []); + const prevActivities = usePrevious(activities, Object.freeze([])); const upsertedActivitiesState = useMemo(() => { const upserts: WebChatActivity[] = []; diff --git a/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx b/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx index 46fbe00d45..f77832b992 100644 --- a/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx +++ b/packages/api/src/providers/ActivityTyping/private/useReduceActivities.spec.tsx @@ -12,6 +12,8 @@ type UseReduceActivitiesFn = Parameters[0]; const ACTIVITY_TEMPLATE = { channelData: { + 'webchat:internal:id': 'a-00001', + 'webchat:internal:position': 0, 'webchat:sequence-id': 0, 'webchat:send-status': undefined }, 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' diff --git a/packages/base/package.json b/packages/base/package.json index 4f3c15b331..aedbd436f5 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -24,6 +24,16 @@ "types": "./dist/botframework-webchat-base.utils.d.ts", "default": "./dist/botframework-webchat-base.utils.js" } + }, + "./valibot": { + "import": { + "types": "./dist/botframework-webchat-base.valibot.d.mts", + "default": "./dist/botframework-webchat-base.valibot.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-base.valibot.d.ts", + "default": "./dist/botframework-webchat-base.valibot.js" + } } }, "author": "Microsoft Corporation", @@ -76,6 +86,7 @@ "type-fest": "^4.41.0", "typescript": "~5.8.3" }, - "dependencies": {}, - "peerDependencies": {} + "peerDependencies": { + "valibot": "1.1.0" + } } diff --git a/packages/base/src/valibot/freeze.ts b/packages/base/src/valibot/freeze.ts new file mode 100644 index 0000000000..e5de0f3e7e --- /dev/null +++ b/packages/base/src/valibot/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/base/src/valibot/index.ts b/packages/base/src/valibot/index.ts new file mode 100644 index 0000000000..9725fed118 --- /dev/null +++ b/packages/base/src/valibot/index.ts @@ -0,0 +1 @@ +export { default as freeze } from './freeze'; diff --git a/packages/base/tsup.config.ts b/packages/base/tsup.config.ts index 86d8e13469..e0c7723ac4 100644 --- a/packages/base/tsup.config.ts +++ b/packages/base/tsup.config.ts @@ -6,7 +6,8 @@ const commonConfig = applyConfig(config => ({ ...config, entry: { 'botframework-webchat-base': './src/index.ts', - 'botframework-webchat-base.utils': './src/utils/index.ts' + 'botframework-webchat-base.utils': './src/utils/index.ts', + 'botframework-webchat-base.valibot': './src/valibot/index.ts' } })); diff --git a/packages/bundle/package.json b/packages/bundle/package.json index e6c571d9a4..c45c90ab02 100644 --- a/packages/bundle/package.json +++ b/packages/bundle/package.json @@ -118,7 +118,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../repack/adaptivecards/package.json\" \"../repack/base64-js/package.json\" \"../repack/botframework-directlinejs/package.json\" \"../base/package.json\" \"../react-valibot/package.json\" \"../tsconfig/package.json\" \"../isomorphic-react/package.json\" \"../isomorphic-react-dom/package.json\" \"../repack/microsoft-cognitiveservices-speech-sdk/package.json\" \"../repack/object-assign/package.json\" \"../repack/react/package.json\" \"../repack/react@baseline/package.json\" \"../repack/react-dom/package.json\" \"../repack/react-dom@baseline/package.json\" \"../repack/react-dom@umd/package.json\" \"../repack/react-is/package.json\" \"../repack/react@umd/package.json\" \"../directlinespeech/package.json\" \"../api/package.json\" \"../component/package.json\" \"../core/package.json\"", + "start": "../../scripts/npm/notify-build.sh \"src\" \"../repack/adaptivecards/package.json\" \"../repack/base64-js/package.json\" \"../repack/botframework-directlinejs/package.json\" \"../isomorphic-react/package.json\" \"../isomorphic-react-dom/package.json\" \"../repack/microsoft-cognitiveservices-speech-sdk/package.json\" \"../repack/react/package.json\" \"../repack/react@baseline/package.json\" \"../repack/react-dom/package.json\" \"../repack/react-dom@baseline/package.json\" \"../repack/react-dom@umd/package.json\" \"../repack/react-is/package.json\" \"../repack/react@umd/package.json\" \"../directlinespeech/package.json\" \"../component/package.json\"", "test:tsd": "tsd" }, "pinDependencies": { diff --git a/packages/component/package.json b/packages/component/package.json index 59fbc4865c..86f6f3f03b 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -93,7 +93,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\" \"../react-hooks/package.json\" \"../react-valibot/package.json\" \"../styles/package.json\" \"../tsconfig/package.json\" \"../api/package.json\" \"../core/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../styles/package.json\" \"../api/package.json\"" }, "pinDependencies": { "@types/react": [ diff --git a/packages/core-graph/.eslintrc.yml b/packages/core-graph/.eslintrc.yml new file mode 100644 index 0000000000..d3fd8eaab6 --- /dev/null +++ b/packages/core-graph/.eslintrc.yml @@ -0,0 +1,2 @@ +extends: + - ../../.eslintrc.production.yml diff --git a/packages/core-graph/.gitignore b/packages/core-graph/.gitignore new file mode 100644 index 0000000000..52536cb56d --- /dev/null +++ b/packages/core-graph/.gitignore @@ -0,0 +1,4 @@ +/*.tgz +/dist/ +/node_modules/ +/tsup.config.bundled_*.mjs diff --git a/packages/core-graph/README.md b/packages/core-graph/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core-graph/package.json b/packages/core-graph/package.json new file mode 100644 index 0000000000..bbf4b4c13d --- /dev/null +++ b/packages/core-graph/package.json @@ -0,0 +1,77 @@ +{ + "name": "@msinternal/botframework-webchat-core-graph", + "version": "0.0.0-0", + "description": "botframework-webchat-core/graph package", + "main": "./dist/botframework-webchat-core-graph.js", + "types": "./dist/botframework-webchat-core-graph.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/botframework-webchat-core-graph.d.mts", + "default": "./dist/botframework-webchat-core-graph.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-core-graph.d.ts", + "default": "./dist/botframework-webchat-core-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/core-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\" \"../base/package.json\"" + }, + "pinDependencies": { + "uuid": [ + "8", + "uuid@9 emit non-ES5 build because of default parameters" + ] + }, + "localDependencies": { + "@msinternal/botframework-webchat-base": "development" + }, + "devDependencies": { + "@msinternal/botframework-webchat-base": "0.0.0-0", + "@testduet/given-when-then": "^0.1.1-main.28754e6", + "@types/uuid": "^8.3.4", + "type-fest": "^4.41.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "core-js-pure": "3.44.0", + "iter-fest": "0.3.0", + "uuid": "8.3.2" + }, + "dependencies": { + "valibot": "1.1.0" + } +} diff --git a/packages/core-graph/src/index.ts b/packages/core-graph/src/index.ts new file mode 100644 index 0000000000..8ab3dcca17 --- /dev/null +++ b/packages/core-graph/src/index.ts @@ -0,0 +1,21 @@ +export { + type GraphMiddleware, + type GraphNode, + type GraphState, + type GraphSubscriber, + type GraphSubscriberRecord, + type ReadableGraph, + type WritableGraph +} from './private/Graph2'; +export { SlantNodeSchema, type SlantNode } from './private/schemas/colorNode'; +export { + DirectLineActivityNodeSchema, + isOfTypeDirectLineActivity, + type DirectLineActivityNode +} from './private/schemas/DirectLineActivityNode'; +export { default as flattenNodeObject } from './private/schemas/flattenNodeObject'; +export { IdentifierSchema, isIdentifier, type Identifier } from './private/schemas/Identifier'; +export { default as isOfType } from './private/schemas/isOfType'; +export { isJSONLiteral, JSONLiteralSchema, type JSONLiteral } from './private/schemas/JSONLiteral'; +export { MessageNodeSchema, type MessageNode } from './private/schemas/MessageNode'; +export { default as SlantGraph } from './private/SlantGraph/SlantGraph'; diff --git a/packages/core-graph/src/private/Graph2.act.spec.ts b/packages/core-graph/src/private/Graph2.act.spec.ts new file mode 100644 index 0000000000..4f74fb4a87 --- /dev/null +++ b/packages/core-graph/src/private/Graph2.act.spec.ts @@ -0,0 +1,54 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { assert, object } from 'valibot'; +import Graph from './Graph2'; +import './schemas/private/expectExtendValibot'; +import './schemas/private/expectIsFrozen'; + +scenario('Graph.act()', bdd => { + bdd + .given('a Graph object', () => new Graph(() => () => request => request)) + .when('act() is called twice nested', graph => { + try { + graph.act(() => { + graph.act(() => { + // Intentionally left blank. + }); + }); + } catch (error) { + return { error }; + } + + return {}; + }) + .then('should throw', (_, { error }) => { + // Because the writable graph does not support parallel writes. + expect(() => { + if (error) { + throw error; + } + }).toThrow('Another transaction is ongoing'); + }); + + bdd + .given('a Graph object', () => new Graph(() => () => request => request)) + .when('act() is called', graph => { + let writableGraph; + + graph.act(graph => { + writableGraph = graph; + }); + + assert(object({}), writableGraph); + + return writableGraph; + }) + .then('the writableGraph argument should not have act', (_, writableGraph) => + // Because the writable graph does not support writes in a nested manner. + expect('act' in writableGraph).toBe(false) + ) + .and('the writableGraph argument should not have subscribe', (_, writableGraph) => + // Because the writable graph should be short-lived. + expect('subscribe' in writableGraph).toBe(false) + ); +}); diff --git a/packages/core-graph/src/private/Graph2.getState.spec.ts b/packages/core-graph/src/private/Graph2.getState.spec.ts new file mode 100644 index 0000000000..451809c15c --- /dev/null +++ b/packages/core-graph/src/private/Graph2.getState.spec.ts @@ -0,0 +1,13 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import Graph from './Graph2'; +import './schemas/private/expectExtendValibot'; +import './schemas/private/expectIsFrozen'; + +scenario('Graph.getState()', bdd => { + bdd + .given('a Graph object', () => new Graph(() => () => request => request)) + .when('getState() is called', graph => graph.getState()) + .then('should return empty Map', (_, state) => expect(state).toBeInstanceOf(Map)) + .and('should return frozen', (_, state) => expect(Object.isFrozen(state)).toBe(true)); +}); diff --git a/packages/core-graph/src/private/Graph2.middleware.spec.ts b/packages/core-graph/src/private/Graph2.middleware.spec.ts new file mode 100644 index 0000000000..959d2166ec --- /dev/null +++ b/packages/core-graph/src/private/Graph2.middleware.spec.ts @@ -0,0 +1,332 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { iteratorFilter, iteratorMap } from 'iter-fest'; +import { fn } from 'jest-mock'; +import Graph from './Graph2'; +import type { Identifier } from './schemas/Identifier'; +import './schemas/private/expectExtendValibot'; +import './schemas/private/expectIsFrozen'; + +type Node = { + readonly '@id': Identifier; + readonly name: string; +}; + +scenario('Graph.middleware', bdd => { + bdd + .given('a Graph object with a middleware which transform "name" property to uppercase', () => { + const enhancer = fn<(nodes: ReadonlyMap) => ReadonlyMap>(); + const graph = new Graph(() => () => nodes => { + enhancer(nodes); + + const nextNodes = new Map(); + + for (const node of nodes.values()) { + nextNodes.set(node['@id'], { '@id': node['@id'], name: node.name.toUpperCase() }); + } + + return Object.freeze(nextNodes); + }); + + return Object.freeze({ enhancer, graph }); + }) + .when('upsert() is called twice', ({ enhancer, graph }) => + graph.act(graph => { + graph.upsert({ + '@id': '_:b1', + name: 'John Doe' + }); + + graph.upsert({ + '@id': '_:b2', + name: 'Mary Doe' + }); + + // Middleware should only be called before commit. + expect(enhancer).not.toHaveBeenCalled(); + }) + ) + .then('should upsert node "name" in uppercase', ({ graph }) => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { + '@id': '_:b1', + name: 'JOHN DOE' + }, + '_:b2': { + '@id': '_:b2', + name: 'MARY DOE' + } + }) + ) + ) + ) + .and('middleware should have been called once with 2 nodes', ({ enhancer }) => { + expect(enhancer).toHaveBeenCalledTimes(1); + expect(enhancer).toHaveBeenNthCalledWith( + 1, + new Map( + Object.entries({ + '_:b1': { '@id': '_:b1', name: 'John Doe' }, + '_:b2': { '@id': '_:b2', name: 'Mary Doe' } + }) + ) + ); + }); + + bdd + .given( + 'a Graph object with a middleware which split one node into two nodes', + () => + new Graph(() => () => upsertingNodeMap => { + const nextNodes = new Map(); + + for (const node of upsertingNodeMap.values()) { + for (const [index, nameToken] of node.name.split(' ').entries()) { + const id: Identifier = `${node['@id']}/${index}`; + + nextNodes.set(id, { '@id': id, name: nameToken }); + } + } + + return nextNodes; + }) + ) + .when('upsert() is called', graph => + graph.act(graph => + graph.upsert({ + '@id': '_:b1', + name: 'John Doe' + }) + ) + ) + .then('should upsert node "name" in uppercase', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1/0': { '@id': '_:b1/0', name: 'John' }, + '_:b1/1': { '@id': '_:b1/1', name: 'Doe' } + }) + ) + ) + ); + + bdd + .given( + 'a Graph object with two middleware: transforms "name" property to uppercase and adds greetings', + () => + new Graph( + () => next => upsertingNodeMap => { + const nextUpsertingNodeMap = next( + new Map( + iteratorMap(upsertingNodeMap.entries(), ([id, node]) => [ + id, + { '@id': node['@id'], name: `"${node.name}"` } + ]) + ) + ); + + return new Map( + iteratorMap(nextUpsertingNodeMap.entries(), ([id, node]) => [ + id, + { '@id': node['@id'], name: `My name is ${node.name}.` } + ]) + ); + }, + () => () => upsertingNodeMap => + new Map( + iteratorMap(upsertingNodeMap.entries(), ([id, node]) => [ + id, + { '@id': node['@id'], name: node.name.toUpperCase() } + ]) + ) + ) + ) + .when('upsert() is called', graph => + graph.act(graph => + graph.upsert({ + '@id': '_:b1', + name: 'John Doe' + }) + ) + ) + .then('should upsert node "name" in uppercase', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { + '@id': '_:b1', + name: 'My name is "JOHN DOE".' + } + }) + ) + ) + ); + + bdd + .given( + 'a Graph object with two middleware that makes request mutable', + () => + new Graph( + () => next => upsertingNodeMap => { + // VERIFY: Make sure request is not mutable. + expect(upsertingNodeMap).toEqual(expect.isFrozen()); + + const result = next(new Map(upsertingNodeMap)); + + expect(result).toEqual(expect.isFrozen()); + + return result; + }, + () => () => nodes => { + // VERIFY: Make sure request is not mutable. + expect(nodes).toEqual(expect.isFrozen()); + + return new Map(nodes); + } + ) + ) + .when('upsert() is called', graph => { + try { + graph.act(graph => graph.upsert({ '@id': '_:b1', name: 'John Doe' })); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should not throw', (_, error) => expect(error).toBeUndefined()); + + type ConversationNode = { + readonly '@id': `_:c${string}`; + readonly '@type': 'Conversation'; + hasPart: readonly { readonly '@id': `_:m${string}` }[]; + }; + + type MessageNode = { + readonly '@id': `_:m${string}`; + readonly '@type': 'Message'; + readonly isPartOf?: { '@id': `_:c${string}` } | undefined; + readonly text: string; + }; + + bdd + .given( + 'a Graph object with middleware which link Message node to Conversation node', + () => + new Graph(({ getState }) => () => upsertingNodeMap => { + const conversationNode = getState().get('_:c1'); + const nextUpsertingNodeMap = new Map(upsertingNodeMap); + + if (conversationNode?.['@type'] === 'Conversation') { + const hasPartIdentifiers = new Set(conversationNode.hasPart.map(node => node['@id'])); + + for (const messageNode of iteratorFilter( + upsertingNodeMap.values(), + (node): node is MessageNode => node['@type'] === 'Message' + )) { + hasPartIdentifiers.add(messageNode['@id']); + + nextUpsertingNodeMap.set(messageNode['@id'], { + ...messageNode, + isPartOf: { '@id': conversationNode['@id'] } + }); + } + + nextUpsertingNodeMap.set(conversationNode['@id'], { + ...conversationNode, + hasPart: Array.from(iteratorMap(hasPartIdentifiers.values(), identifier => ({ '@id': identifier }))) + }); + } + + return nextUpsertingNodeMap; + }) + ) + .when('upsert(ConversationNode) is called', graph => { + graph.act(graph => + graph.upsert({ + '@id': '_:c1', + '@type': 'Conversation', + hasPart: Object.freeze([]) + }) + ); + }) + .then('the graph should have Conversation node', graph => { + expect(graph.getState()).toEqual( + new Map(Object.entries({ '_:c1': { '@id': '_:c1', '@type': 'Conversation', hasPart: [] } })) + ); + }) + .when('upsert(MessageNode) is called', graph => { + graph.act(graph => + graph.upsert({ + '@id': '_:m1', + '@type': 'Message', + text: 'Hello, World!' + }) + ); + }) + .then('the graph should have Conversation node linked to the new Message node', graph => { + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:c1': { + '@id': '_:c1', + '@type': 'Conversation', + hasPart: [{ '@id': '_:m1' }] + }, + '_:m1': { + '@id': '_:m1', + '@type': 'Message', + isPartOf: { '@id': '_:c1' }, + text: 'Hello, World!' + } + }) + ) + ); + }); + + bdd + .given('a Graph with a passthrough middleware', () => new Graph(() => next => request => next(request))) + .when('upserting a node', graph => { + try { + graph.act(graph => graph.upsert({ '@id': '_:b1' })); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + if (error) { + throw error; + } + }).toThrow('At least one middleware must not fallthrough'); + }); + + bdd + .given( + 'a Graph with a middleware that messed up keys', + () => + new Graph( + () => () => request => Object.freeze(new Map([['_:x1', request.get('_:b1')!]])) + ) + ) + .when('upserting a node', graph => { + try { + graph.act(graph => graph.upsert({ '@id': '_:b1', name: 'John Doe' })); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + if (error) { + throw error; + } + }).toThrow('Key returned in Map must match `@id` in value'); + }); +}); diff --git a/packages/core-graph/src/private/Graph2.subscribe.spec.ts b/packages/core-graph/src/private/Graph2.subscribe.spec.ts new file mode 100644 index 0000000000..1dd867b760 --- /dev/null +++ b/packages/core-graph/src/private/Graph2.subscribe.spec.ts @@ -0,0 +1,124 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { fn } from 'jest-mock'; +import { assert, map, string, unknown } from 'valibot'; +import Graph, { type GraphState } from './Graph2'; +import './schemas/private/expectExtendValibot'; +import './schemas/private/expectIsFrozen'; + +scenario('Graph.subscribe()', bdd => { + bdd + .given('a Graph object and a subscriber', () => { + const graph = new Graph(() => () => request => request); + const subscriber = fn(); + + graph.subscribe(subscriber); + + return { graph, subscriber }; + }) + .when('act().getState() is called', ({ graph }) => { + let returnValue; + + graph.act(({ getState }) => { + returnValue = getState(); + }); + + return returnValue; + }) + .then('context.state should be of type Map', (_, state) => expect(state).toBeInstanceOf(Map)) + .and('context.state should be frozen', (_, state) => expect(Object.isFrozen(state)).toBe(true)) + .and('subscriber should not have been called', ({ subscriber }) => + // Because there are no changes. + expect(subscriber).not.toHaveBeenCalled() + ); + + bdd + .given('a Graph object and a subscriber', () => { + const graph = new Graph(() => () => request => request); + const subscriber = fn(); + const unsubscribe = graph.subscribe(subscriber); + + return { graph, subscriber, unsubscribe }; + }) + .when('act().upsert() is called', ({ graph }) => { + let returnValue: GraphState | undefined; + + graph.act(({ getState, upsert }) => { + upsert({ '@id': '_:b1' }); + + returnValue = getState(); + }); + + assert(map(string(), unknown()), returnValue); + + return returnValue; + }) + .then('subscriber should have been called once', ({ subscriber }) => expect(subscriber).toHaveBeenCalledTimes(1)) + .and('subscriber should have been called with changed node identifiers', ({ subscriber }) => + expect(subscriber).toHaveBeenNthCalledWith(1, { upsertedNodeIdentifiers: new Set(['_:b1']) }) + ) + .and('subscriber should have been called with frozen', ({ subscriber }) => { + expect(subscriber).toHaveBeenNthCalledWith(1, expect.isFrozen()); + expect(subscriber).toHaveBeenNthCalledWith(1, { upsertedNodeIdentifiers: expect.isFrozen() }); + }) + .and('getState() should have the newly added node', ({ graph }) => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { + '@id': '_:b1' + } + }) + ) + ) + ) + .and('getState() should be frozen', ({ graph }) => expect(graph.getState()).toEqual(expect.isFrozen())) + .and('getState() alled during act() should not do dirty read', (_, dirtyGraph) => { + expect(dirtyGraph).toEqual(new Map()); + expect(dirtyGraph).toEqual(expect.isFrozen()); + }) + .when('unsubscribe() is called', ({ unsubscribe }) => unsubscribe()) + .then('act().snapshot() should not call subscriber', ({ graph, subscriber }) => { + expect(subscriber).toHaveBeenCalledTimes(1); // Subscriber have been called once previously, so it should kept at 1. + + graph.act(graph => graph.upsert({ '@id': '_:b1' })); + + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + bdd + .given('a Graph object and a subscriber which will call act() when triggered', () => { + const graph = new Graph(() => () => request => request); + const subscriber = fn(() => { + // Should throw. + graph.act(({ upsert }) => { + upsert({ '@id': '_:b2' }); + }); + }); + + graph.subscribe(subscriber); + + return { graph, subscriber }; + }) + .when('act().upsert() is called', ({ graph }) => { + try { + graph.act(({ upsert }) => { + upsert({ '@id': '_:b1' }); + }); + } catch (error) { + return error; + } + + return undefined; + }) + .then('getState() should return both nodes', ({ graph }) => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { '@id': '_:b1' }, + '_:b2': { '@id': '_:b2' } + }) + ) + ) + ); +}); diff --git a/packages/core-graph/src/private/Graph2.ts b/packages/core-graph/src/private/Graph2.ts new file mode 100644 index 0000000000..055e3b9182 --- /dev/null +++ b/packages/core-graph/src/private/Graph2.ts @@ -0,0 +1,151 @@ +import { applyMiddleware, type Middleware } from 'handler-chain'; +import { iteratorEvery } from 'iter-fest'; +import { assert, check, map, object, pipe } from 'valibot'; +import { IdentifierSchema, type Identifier } from './schemas/Identifier'; + +type GraphSubscriberRecord = { + readonly upsertedNodeIdentifiers: ReadonlySet; +}; + +type GraphSubscriber = (event: GraphSubscriberRecord) => void; + +type GraphNode = { '@id': Identifier }; + +type GraphMiddleware = Middleware< + ReadonlyMap, + ReadonlyMap, + { readonly getState: () => GraphState } +>; + +type GraphState = ReadonlyMap; + +type ReadableGraph = { + readonly act: (fn: (graph: WritableGraph) => void) => void; + readonly getState: () => GraphState; + readonly subscribe: (subscriber: GraphSubscriber) => void; +}; + +type WritableGraph = { + readonly getState: () => GraphState; + readonly upsert: (...nodes: readonly TInput[]) => void; +}; + +const requestSchema = pipe( + map(IdentifierSchema, object({ '@id': IdentifierSchema })), + check( + // TODO: Iterator.every is since iOS 18.4, we still need to use ponyfill for now. + value => iteratorEvery(value.entries(), ([key, node]) => key === node['@id']), + 'Key returned in Map must match `@id` in value' + ) +); + +const middlewareValidator: GraphMiddleware = () => next => request => { + assert(requestSchema, request); + + const result = next(Object.freeze(request)); + + assert(requestSchema, result); + + return Object.freeze(result); +}; + +class Graph2 implements ReadableGraph { + #busy = false; + #middleware: GraphMiddleware; + #state: GraphState = Object.freeze(new Map()); + #subscribers: Set = new Set(); + + constructor( + firstMiddleware: GraphMiddleware, + ...restMiddleware: readonly GraphMiddleware[] + ) { + // Interleaves every middleware with a validator to protect request. + this.#middleware = applyMiddleware( + middlewareValidator, + ...[firstMiddleware, ...restMiddleware].flatMap>(middleware => [ + middleware, + middlewareValidator + ]) + ); + } + + act(fn: (graph: WritableGraph) => void) { + if (this.#busy) { + throw new Error('Another transaction is ongoing'); + } + + this.#busy = true; + + let record: GraphSubscriberRecord | undefined; + + try { + const getState = this.getState.bind(this); + const upsertedNodes = new Map(); + + fn( + Object.freeze({ + getState, + upsert(...nodes: readonly TInput[]) { + for (const node of nodes) { + const id = node['@id']; + + if (upsertedNodes.has(id)) { + throw new Error(`Cannot upsert a node multiple times in a single transaction (@id = "${id}")`); + } + + upsertedNodes.set(id, node); + } + } + }) + ); + + const nextState = new Map(this.#state); + const upsertedNodeIdentifiers = new Set(); + + for (const enhancedNode of this.#middleware({ getState })(() => { + throw new Error('At least one middleware must not fallthrough'); + })(Object.freeze(upsertedNodes)).values()) { + nextState.set(enhancedNode['@id'], Object.freeze({ ...enhancedNode })); + upsertedNodeIdentifiers.add(enhancedNode['@id']); + } + + if (upsertedNodeIdentifiers.size) { + this.#state = Object.freeze(nextState); + + // After this line, there must be no more write operations on this object instance. + record = Object.freeze({ upsertedNodeIdentifiers: Object.freeze(upsertedNodeIdentifiers) }); + } + } finally { + this.#busy = false; + } + + if (record) { + for (const subscriber of this.#subscribers) { + subscriber(record); + } + } + } + + getState(): GraphState { + return this.#state; + } + + subscribe(subscriber: GraphSubscriber): () => void { + this.#subscribers.add(subscriber); + + return () => { + this.#subscribers.delete(subscriber); + }; + } +} + +export default Graph2; +export { + type GraphMiddleware, + type GraphNode, + type GraphState, + type GraphSubscriber, + type GraphSubscriberRecord, + type ReadableGraph, + type WritableGraph +}; diff --git a/packages/core-graph/src/private/Graph2.upsert.spec.ts b/packages/core-graph/src/private/Graph2.upsert.spec.ts new file mode 100644 index 0000000000..a5731f953f --- /dev/null +++ b/packages/core-graph/src/private/Graph2.upsert.spec.ts @@ -0,0 +1,59 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import Graph from './Graph2'; +import './schemas/private/expectExtendValibot'; +import './schemas/private/expectIsFrozen'; +import type { Identifier } from './schemas/Identifier'; + +scenario('Graph.upsert()', bdd => { + bdd + .given( + 'a Graph object', + () => new Graph<{ readonly '@id': Identifier; readonly name: string }>(() => () => request => request) + ) + .when('act().upsert() is called', graph => graph.act(graph => graph.upsert({ '@id': '_:b1', name: 'John Doe' }))) + .then('getState() should return the new node', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { '@id': '_:b1', name: 'John Doe' } + }) + ) + ) + ) + .when('act().upsert() is called with another node', graph => + graph.act(graph => graph.upsert({ '@id': '_:b2', name: 'Mary Doe' })) + ) + .then('getState() should return both nodes', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { '@id': '_:b1', name: 'John Doe' }, + '_:b2': { '@id': '_:b2', name: 'Mary Doe' } + }) + ) + ) + ); + + bdd + .given( + 'a Graph object', + () => new Graph<{ readonly '@id': Identifier; readonly name: string }>(() => () => request => request) + ) + .when('act().upsert() is called twice with node of same @id', graph => { + try { + graph.act(graph => graph.upsert({ '@id': '_:b1', name: 'John Doe' }, { '@id': '_:b1', name: 'Mary Doe' })); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + if (error) { + throw error; + } + }).toThrow('Cannot upsert a node multiple times in a single transaction (@id = "_:b1")'); + }); +}); diff --git a/packages/core-graph/src/private/SlantGraph/SlantGraph.autoInversion.spec.ts b/packages/core-graph/src/private/SlantGraph/SlantGraph.autoInversion.spec.ts new file mode 100644 index 0000000000..715b23777f --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/SlantGraph.autoInversion.spec.ts @@ -0,0 +1,283 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { fn } from 'jest-mock'; +import { GraphSubscriber } from '../Graph2'; +import SlantGraph from './SlantGraph'; + +scenario('SlantGraph auto-inversion', bdd => { + bdd + .given('a SlantGraph', () => new SlantGraph()) + .when('upserting 2 nodes with hasPart only', graph => + graph.act(graph => + graph.upsert( + { + '@id': '_:c1', + '@type': 'Conversation', + hasPart: [{ '@id': '_:m1' }], + title: 'Adipisicing voluptate aute mollit culpa nostrud labore ea deserunt nulla culpa nisi ea.' + }, + { + '@id': '_:m1', + '@type': 'MessageLike', + text: 'Hello, World!' + } + ) + ) + ) + .then('should perform auto-inversing', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:c1': { + '@id': '_:c1', + '@type': ['Conversation'], + hasPart: [{ '@id': '_:m1' }], + title: ['Adipisicing voluptate aute mollit culpa nostrud labore ea deserunt nulla culpa nisi ea.'] + }, + '_:m1': { + '@id': '_:m1', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Hello, World!'] + } + }) + ) + ) + ); + + bdd + .given('a SlantGraph', () => new SlantGraph()) + .when('upserting 2 nodes with isPartOf only', graph => + graph.act(graph => + graph.upsert( + { + '@id': '_:c1', + '@type': 'Conversation', + abstract: 'Adipisicing voluptate aute mollit culpa nostrud labore ea deserunt nulla culpa nisi ea.' + }, + { + '@id': '_:m1', + '@type': 'MessageLike', + isPartOf: [{ '@id': '_:c1' }], + text: 'Hello, World!' + } + ) + ) + ) + .then('should perform auto-inversing', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:c1': { + '@id': '_:c1', + '@type': ['Conversation'], + abstract: ['Adipisicing voluptate aute mollit culpa nostrud labore ea deserunt nulla culpa nisi ea.'], + hasPart: [{ '@id': '_:m1' }] + }, + '_:m1': { + '@id': '_:m1', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Hello, World!'] + } + }) + ) + ) + ); +}); + +scenario('Graph class', bdd => { + bdd + .given('a graph with a Conversation node', () => { + const graph = new SlantGraph(); + + graph.act(graph => + graph.upsert({ + '@id': '_:c1', + '@type': ['Conversation'], + title: ['Once upon a time'] + }) + ); + + return { graph }; + }) + .and( + 'a subscriber', + condition => { + const subscriber = fn(); + const unsubscribe = condition.graph.subscribe(subscriber); + + return { ...condition, subscriber, unsubscribe }; + }, + ({ unsubscribe }) => unsubscribe() + ) + .when('a Message node is upserted', ({ graph }) => { + graph.act(graph => + graph.upsert({ + '@id': '_:m1', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Hello, World!'] + }) + ); + }) + .then('the graph should have the node', ({ graph }) => { + expect(Array.from(graph.getState().entries())).toEqual([ + [ + '_:c1', + { '@id': '_:c1', '@type': ['Conversation'], hasPart: [{ '@id': '_:m1' }], title: ['Once upon a time'] } + ], + ['_:m1', { '@id': '_:m1', '@type': ['MessageLike'], isPartOf: [{ '@id': '_:c1' }], text: ['Hello, World!'] }] + ]); + }) + .and('observer should receive both nodes', ({ subscriber }) => { + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { upsertedNodeIdentifiers: new Set(['_:m1', '_:c1']) }); + }) + .when('two more Message nodes are upserted', ({ graph }) => { + graph.act(graph => + graph.upsert( + { + '@id': '_:m2', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Aloha!'] + }, + { + '@id': '_:m3', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Good morning!'] + } + ) + ); + }) + .then('the graph should have the node', ({ graph }) => { + expect(Array.from(graph.getState().entries())).toEqual([ + [ + '_:c1', + { + '@id': '_:c1', + '@type': ['Conversation'], + hasPart: [{ '@id': '_:m1' }, { '@id': '_:m2' }, { '@id': '_:m3' }], + title: ['Once upon a time'] + } + ], + ['_:m1', { '@id': '_:m1', '@type': ['MessageLike'], isPartOf: [{ '@id': '_:c1' }], text: ['Hello, World!'] }], + ['_:m2', { '@id': '_:m2', '@type': ['MessageLike'], isPartOf: [{ '@id': '_:c1' }], text: ['Aloha!'] }], + ['_:m3', { '@id': '_:m3', '@type': ['MessageLike'], isPartOf: [{ '@id': '_:c1' }], text: ['Good morning!'] }] + ]); + }) + .and('observer should receive all 3 nodes', ({ subscriber }) => { + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenNthCalledWith(2, { upsertedNodeIdentifiers: new Set(['_:m2', '_:m3', '_:c1']) }); + }) + .when('a Message is disconnected from the Conversation', ({ graph }) => { + graph.act(graph => + graph.upsert({ + '@id': '_:m1', + '@type': ['MessageLike'], + text: ['Hello, World!'] + }) + ); + }) + .then('the Conversation.hasPart should have the Message unreferenced', ({ graph }) => { + expect(Array.from(graph.getState().entries())).toEqual([ + [ + '_:c1', + { + '@id': '_:c1', + '@type': ['Conversation'], + hasPart: [{ '@id': '_:m2' }, { '@id': '_:m3' }], + title: ['Once upon a time'] + } + ], + ['_:m1', { '@id': '_:m1', '@type': ['MessageLike'], text: ['Hello, World!'] }], + ['_:m2', { '@id': '_:m2', '@type': ['MessageLike'], isPartOf: [{ '@id': '_:c1' }], text: ['Aloha!'] }], + ['_:m3', { '@id': '_:m3', '@type': ['MessageLike'], isPartOf: [{ '@id': '_:c1' }], text: ['Good morning!'] }] + ]); + }) + .and('observer should receive the detached Message node and Conversation node', ({ subscriber }) => { + expect(subscriber).toHaveBeenCalledTimes(3); + expect(subscriber).toHaveBeenNthCalledWith(3, { upsertedNodeIdentifiers: new Set(['_:m1', '_:c1']) }); + }) + .when('the Conversation detached all Message', ({ graph }) => { + graph.act(graph => + graph.upsert({ + '@id': '_:c1', + '@type': ['Conversation'], + title: ['Once upon a time'] + }) + ); + }) + .then('all nodes should be disconnected', ({ graph }) => { + expect(Array.from(graph.getState().entries())).toEqual([ + [ + '_:c1', + { + '@id': '_:c1', + '@type': ['Conversation'], + title: ['Once upon a time'] + } + ], + ['_:m1', { '@id': '_:m1', '@type': ['MessageLike'], text: ['Hello, World!'] }], + ['_:m2', { '@id': '_:m2', '@type': ['MessageLike'], text: ['Aloha!'] }], + ['_:m3', { '@id': '_:m3', '@type': ['MessageLike'], text: ['Good morning!'] }] + ]); + }) + .and('observer should receive the detached Message node and Conversation node', ({ subscriber }) => { + expect(subscriber).toHaveBeenCalledTimes(4); + expect(subscriber).toHaveBeenNthCalledWith(4, { upsertedNodeIdentifiers: new Set(['_:c1', '_:m2', '_:m3']) }); + }); + + // TODO: [P*] Add a child with a non-existing parent, should throw. + // TODO: [P*] Add a child with a parent upserted after the child. + // TODO: [P*] Add a parent with a non-existing child, should throw. + // TODO: [P*] Add a parent with a child upserted after the parent. + + bdd + .given('a graph with a Conversation node with a Message', () => { + const graph = new SlantGraph(); + + graph.act(graph => { + graph.upsert({ + '@id': '_:c1', + '@type': ['Conversation'], + title: 'Once upon a time' + }); + + graph.upsert({ + '@id': '_:m1', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Hello, World!'] + }); + }); + + return { graph }; + }) + .and( + 'a subscriber', + condition => { + const subscriber = fn(); + const unsubscribe = condition.graph.subscribe(subscriber); + + return { ...condition, subscriber, unsubscribe }; + }, + ({ unsubscribe }) => unsubscribe() + ) + .when('the Message node is updated', ({ graph }) => { + graph.act(graph => + graph.upsert({ + '@id': '_:m1', + '@type': ['MessageLike'], + isPartOf: [{ '@id': '_:c1' }], + text: ['Aloha!'] + }) + ); + }) + .then('the observer should only return the Message', ({ subscriber }) => { + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { upsertedNodeIdentifiers: new Set(['_:m1']) }); + }); +}); diff --git a/packages/core-graph/src/private/SlantGraph/SlantGraph.spec.ts b/packages/core-graph/src/private/SlantGraph/SlantGraph.spec.ts new file mode 100644 index 0000000000..d40f235527 --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/SlantGraph.spec.ts @@ -0,0 +1,146 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import SlantGraph from './SlantGraph'; + +scenario('SlantGraph arrayize', bdd => { + bdd + .given('a SlantGraph', () => new SlantGraph()) + .when('upserting a node with literal properties', graph => + graph.act(graph => + graph.upsert({ + '@id': '_:b1', + '@type': 'Person', + name: 'John Doe' + }) + ) + ) + .then('should arrayize all properties', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { + '@id': '_:b1', + '@type': ['Person'], + name: ['John Doe'] + } + }) + ) + ) + ); +}); + +scenario('SlantGraph handling JSON literals', bdd => { + bdd + .given('a SlantGraph', () => new SlantGraph()) + .when('upserting a node with JSON literal property', graph => + graph.act(graph => + graph.upsert({ + '@id': '_:b1', + '@type': 'LandmarksOrHistoricalBuildings', + description: 'The Empire State Building is a 102-story landmark in New York City.', + geo: { + '@type': '@json', + '@value': { + latitude: '40.75', + longitude: '73.98' + } + }, + image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', + name: 'The Empire State Building' + }) + ) + ) + .then('should arrayize the property', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:b1': { + '@id': '_:b1', + '@type': ['LandmarksOrHistoricalBuildings'], + description: ['The Empire State Building is a 102-story landmark in New York City.'], + geo: [ + { + '@type': '@json', + '@value': { + latitude: '40.75', + longitude: '73.98' + } + } + ], + image: ['http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg'], + name: ['The Empire State Building'] + } + }) + ) + ) + ); +}); + +scenario('SlantGraph handling blank node', bdd => { + bdd + .given('a SlantGraph', () => new SlantGraph()) + .when('upserting a node of address', graph => + graph.act(graph => + graph.upsert({ + '@id': '_:a1', + '@type': 'PostalAddress', + addressLocality: 'New York', + addressRegion: 'NY', + postalCode: '10118', + streetAddress: '350 Fifth Avenue' + }) + ) + ) + .then('should have the address in the graph', graph => + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:a1': { + '@id': '_:a1', + '@type': ['PostalAddress'], + addressLocality: ['New York'], + addressRegion: ['NY'], + postalCode: ['10118'], + streetAddress: ['350 Fifth Avenue'] + } + }) + ) + ) + ) + .when('upserting a building linked to the address', graph => { + graph.act(graph => + graph.upsert({ + '@id': '_:b1', + '@type': 'LandmarksOrHistoricalBuildings', + address: { '@id': '_:a1' }, + description: 'The Empire State Building is a 102-story landmark in New York City.', + image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', + name: 'The Empire State Building' + }) + ); + }) + .then('should have linked in the graph', graph => { + expect(graph.getState()).toEqual( + new Map( + Object.entries({ + '_:a1': { + '@id': '_:a1', + '@type': ['PostalAddress'], + addressLocality: ['New York'], + addressRegion: ['NY'], + postalCode: ['10118'], + streetAddress: ['350 Fifth Avenue'] + }, + '_:b1': { + '@id': '_:b1', + '@type': ['LandmarksOrHistoricalBuildings'], + address: [{ '@id': '_:a1' }], + description: ['The Empire State Building is a 102-story landmark in New York City.'], + image: ['http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg'], + name: ['The Empire State Building'] + } + }) + ) + ); + }); +}); diff --git a/packages/core-graph/src/private/SlantGraph/SlantGraph.ts b/packages/core-graph/src/private/SlantGraph/SlantGraph.ts new file mode 100644 index 0000000000..381d127b72 --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/SlantGraph.ts @@ -0,0 +1,23 @@ +import Graph from '../Graph2'; +import { type SlantNode } from '../schemas/colorNode'; +import type { Identifier } from '../schemas/Identifier'; +import assertSlantNode from './private/assertSlantNode'; +import autoInversion from './private/autoInversion'; +import color from './private/color'; +import terminator from './private/terminator'; + +type AnyNode = Record & { + readonly '@id': Identifier; + readonly '@type': string | readonly string[]; +}; + +class SlantGraph extends Graph { + constructor() { + // `autoInversion` and `terminator` must run after `assertSlantNode` as they assume all input are validated `SlantNode`. + super(color, assertSlantNode, autoInversion, terminator); + } +} + +export default SlantGraph; + +export { type AnyNode }; diff --git a/packages/core-graph/src/private/SlantGraph/private/assertSlantNode.ts b/packages/core-graph/src/private/SlantGraph/private/assertSlantNode.ts new file mode 100644 index 0000000000..af0af7fde4 --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/private/assertSlantNode.ts @@ -0,0 +1,26 @@ +import { assert, type BaseSchema } from 'valibot'; +import { type GraphMiddleware } from '../../Graph2'; +import { SlantNodeSchema, type SlantNode } from '../../schemas/colorNode'; +import { DirectLineActivityNodeSchema } from '../../schemas/DirectLineActivityNode'; +import isOfType from '../../schemas/isOfType'; +import { MessageNodeSchema } from '../../schemas/MessageNode'; +import { type AnyNode } from '../SlantGraph'; + +const VALIDATION_SCHEMAS_BY_TYPE = new Map>([ + ['Message', MessageNodeSchema], + ['urn:microsoft:webchat:direct-line-activity', DirectLineActivityNodeSchema] +]); + +const assertSlantNode: GraphMiddleware = () => next => upsertingNodeMap => { + for (const node of upsertingNodeMap.values()) { + assert(SlantNodeSchema, node); + + for (const [type, schema] of VALIDATION_SCHEMAS_BY_TYPE) { + isOfType(node, type) && assert(schema, node); + } + } + + return next(upsertingNodeMap); +}; + +export default assertSlantNode; diff --git a/packages/core-graph/src/private/SlantGraph/private/autoInversion.ts b/packages/core-graph/src/private/SlantGraph/private/autoInversion.ts new file mode 100644 index 0000000000..1b65392f3e --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/private/autoInversion.ts @@ -0,0 +1,154 @@ +// @ts-expect-error No @types/core-js-pure. +import difference from 'core-js-pure/features/set/difference'; +import { type GraphMiddleware } from '../../Graph2'; +import { type SlantNode } from '../../schemas/colorNode'; +import type { Identifier } from '../../schemas/Identifier'; +import { type NodeReference } from '../../schemas/NodeReference'; +import type { AnyNode } from '../SlantGraph'; + +// TODO: [P1] Set.difference is supported since Chrome 122 and iOS 17. However, not Node.js 18 as used by CI pipeline. +function setDifference(set1: ReadonlySet, set2: ReadonlySet): Set { + return difference(set1, set2); +} + +function nodeReferenceListToIdentifierSet(nodeReferences: readonly NodeReference[] | undefined): Set { + return new Set(nodeReferences?.map(ref => ref['@id'])); +} + +// TODO: [P*] Review this auto-inversing middleware. +const autoInversion: GraphMiddleware = + ({ getState }) => + next => + // "autoInversion" receives SlantNode instead of AnyNode because prior middleware already did the transformation. + // @ts-expect-error + (upsertingNodeMap: Map) => { + const state = getState(); + const nextUpsertingNodeMap = new Map(upsertingNodeMap as any); + + function markAsChanged(...nodes: readonly SlantNode[]) { + for (const node of nodes) { + nextUpsertingNodeMap.set(node['@id'], node); + } + } + + function getDirtyNode(id: Identifier) { + const node = (nextUpsertingNodeMap.get(id) as SlantNode | undefined) ?? state.get(id); + + if (!node) { + throw new Error(`Cannot find node with @id "${id}"`); + } + + return node; + } + + function updateNode(id: Identifier, fn: (node: SlantNode) => SlantNode): boolean { + const node = getDirtyNode(id); + + if (!node) { + throw new Error(`Cannot find node with @id of ${id} to update`); + } + + const nextNode = fn(node); + + if (!Object.is(node, nextNode)) { + markAsChanged(nextNode); + } + + return true; + } + + for (const [id, node] of upsertingNodeMap) { + const preCommitNode = state.get(id); + + // Remove hasPart/isPartOf if the existing node does not match the upserted node. + if (preCommitNode) { + const removedHasPartIdSet = setDifference( + nodeReferenceListToIdentifierSet(preCommitNode.hasPart), + nodeReferenceListToIdentifierSet(node.hasPart) + ); + + for (const removedHasPartId of removedHasPartIdSet) { + updateNode(removedHasPartId, childNode => { + const { isPartOf, ...childNodeWithoutIsPartOf } = childNode; + + if (isPartOf) { + const nextIsPartOf = isPartOf.filter(ref => ref['@id'] !== id); + + if (isPartOf.length !== nextIsPartOf.length) { + return Object.freeze({ + ...childNodeWithoutIsPartOf, + ...(nextIsPartOf.length ? { isPartOf: nextIsPartOf } : {}) + }); + } + } + + return childNode; + }); + } + + const removedIsPartOfIdSet = setDifference( + nodeReferenceListToIdentifierSet(preCommitNode.isPartOf), + nodeReferenceListToIdentifierSet(node.isPartOf) + ); + + for (const removedIsPartOfId of removedIsPartOfIdSet) { + updateNode(removedIsPartOfId, parentNode => { + const { hasPart, ...parentNodeWithoutHasPart } = parentNode; + + if (hasPart) { + const nextHasPart = hasPart.filter(ref => ref['@id'] !== id); + + if (hasPart.length !== nextHasPart.length) { + return Object.freeze({ + ...parentNodeWithoutHasPart, + ...(nextHasPart.length ? { hasPart: nextHasPart } : {}) + }); + } + } + + return parentNode; + }); + } + } + + const addedHasPartIdSet = setDifference( + nodeReferenceListToIdentifierSet(node.hasPart), + nodeReferenceListToIdentifierSet(preCommitNode?.hasPart ?? []) + ); + + for (const addedHasPartId of addedHasPartIdSet) { + updateNode(addedHasPartId, childNode => { + if (childNode.isPartOf?.find(ref => ref['@id'] === id)) { + return childNode; + } + + return Object.freeze({ + ...childNode, + isPartOf: Object.freeze([...(childNode.isPartOf ?? []), Object.freeze({ '@id': id })]) + }); + }); + } + + const addedIsPartOfIdSet = setDifference( + nodeReferenceListToIdentifierSet(node.isPartOf), + nodeReferenceListToIdentifierSet(preCommitNode?.isPartOf ?? []) + ); + + for (const addedIsPartOfId of addedIsPartOfIdSet) { + updateNode(addedIsPartOfId, parentNode => { + if (parentNode.hasPart?.find(ref => ref['@id'] === id)) { + return parentNode; + } + + return Object.freeze({ + ...parentNode, + hasPart: Object.freeze([...(parentNode.hasPart ?? []), Object.freeze({ '@id': id })]) + }); + }); + } + } + + return next(nextUpsertingNodeMap); + }; + +export default autoInversion; diff --git a/packages/core-graph/src/private/SlantGraph/private/color.ts b/packages/core-graph/src/private/SlantGraph/private/color.ts new file mode 100644 index 0000000000..58e36938be --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/private/color.ts @@ -0,0 +1,19 @@ +import { type GraphMiddleware } from '../../Graph2'; +import colorNode, { type SlantNode } from '../../schemas/colorNode'; +import flattenNodeObject from '../../schemas/flattenNodeObject'; +import type { Identifier } from '../../schemas/Identifier'; +import type { AnyNode } from '../SlantGraph'; + +const color: GraphMiddleware = () => next => upsertingNodeMap => { + const nextUpsertingNodeMap = new Map(); + + for (const node of upsertingNodeMap.values()) { + for (const flattenedNode of flattenNodeObject(node).graph) { + nextUpsertingNodeMap.set(flattenedNode['@id'], colorNode(flattenedNode)); + } + } + + return next(nextUpsertingNodeMap); +}; + +export default color; diff --git a/packages/core-graph/src/private/SlantGraph/private/terminator.ts b/packages/core-graph/src/private/SlantGraph/private/terminator.ts new file mode 100644 index 0000000000..9047a3a0fc --- /dev/null +++ b/packages/core-graph/src/private/SlantGraph/private/terminator.ts @@ -0,0 +1,14 @@ +import { type GraphMiddleware } from '../../Graph2'; +import { type SlantNode } from '../../schemas/colorNode'; +import type { Identifier } from '../../schemas/Identifier'; +import type { AnyNode } from '../SlantGraph'; + +const terminator: GraphMiddleware = + () => + () => + // "terminator" receives SlantNode instead of AnyNode because prior middleware already did the transformation. + // @ts-expect-error + (upsertingNodeMap: Map) => + upsertingNodeMap; + +export default terminator; diff --git a/packages/core-graph/src/private/schemas/BlankNodeIdentifier.ts b/packages/core-graph/src/private/schemas/BlankNodeIdentifier.ts new file mode 100644 index 0000000000..ed624559f1 --- /dev/null +++ b/packages/core-graph/src/private/schemas/BlankNodeIdentifier.ts @@ -0,0 +1,17 @@ +import { is, pipe, startsWith, string, type GenericSchema, type InferOutput } from 'valibot'; + +/** + * Schema of JSON-LD blank node identifier. Must be prefixed with `_:`. + * + * @see {@link https://www.w3.org/TR/rdf11-concepts/#dfn-blank-node-identifier RDF 1.1 Concepts and Abstract Syntax: Blank node identifier} + */ +const BlankNodeIdentifierSchema = pipe( + string('Blank node identifier must be a string'), + startsWith('_:', 'Blank node identifier must starts with _:') +) as GenericSchema<`_:${string}`>; + +type BlankNodeIdentifier = InferOutput; + +const isBlankNodeIdentifier = is.bind(BlankNodeIdentifierSchema); + +export { BlankNodeIdentifierSchema, isBlankNodeIdentifier, type BlankNodeIdentifier }; diff --git a/packages/core-graph/src/private/schemas/DirectLineActivityNode.ts b/packages/core-graph/src/private/schemas/DirectLineActivityNode.ts new file mode 100644 index 0000000000..3779e6d35b --- /dev/null +++ b/packages/core-graph/src/private/schemas/DirectLineActivityNode.ts @@ -0,0 +1,39 @@ +import { + array, + includes, + is, + number, + object, + optional, + pipe, + readonly, + string, + tuple, + type InferOutput +} from 'valibot'; +import { IdentifierSchema } from './Identifier'; +import { JSONLiteralSchema } from './JSONLiteral'; +import { NodeReferenceSchema } from './NodeReference'; + +// TODO: [P1] Maybe we should not always need readonly() but only add it as needed. +const DirectLineActivityNodeSchema = pipe( + object({ + '@id': IdentifierSchema, + '@type': pipe(array(string()), includes('urn:microsoft:webchat:direct-line-activity')), + // TODO: [P*] Checks why identifier could be undefined. + // Related to /html2/accessibility/suggestedActions/stackedLayout.ariaAttributes.html. + identifier: optional(array(IdentifierSchema)), + position: tuple([number()]), + // TODO: [P*] Remove optional(), every activity should have sender. + sender: optional(tuple([NodeReferenceSchema])), + 'urn:microsoft:webchat:direct-line-activity:raw-json': tuple([JSONLiteralSchema]), + 'urn:microsoft:webchat:direct-line-activity:type': tuple([string()]) + }), + readonly() +); + +const isOfTypeDirectLineActivity = is.bind(undefined, DirectLineActivityNodeSchema); + +type DirectLineActivityNode = InferOutput; + +export { DirectLineActivityNodeSchema, isOfTypeDirectLineActivity, type DirectLineActivityNode }; diff --git a/packages/core-graph/src/private/schemas/FlatNodeObject.ts b/packages/core-graph/src/private/schemas/FlatNodeObject.ts new file mode 100644 index 0000000000..3099f498f3 --- /dev/null +++ b/packages/core-graph/src/private/schemas/FlatNodeObject.ts @@ -0,0 +1,57 @@ +import { freeze } from '@msinternal/botframework-webchat-base/valibot'; +import { array, minLength, null_, objectWithRest, optional, pipe, string, union, type InferOutput } from 'valibot'; + +import { IdentifierSchema } from './Identifier'; +import { JSONLiteralSchema } from './JSONLiteral'; +import { LiteralSchema } from './Literal'; +import { NodeReferenceSchema } from './NodeReference'; + +const FlatNodeObjectPropertyValueSchema = union( + [ + pipe( + array( + union( + [LiteralSchema, JSONLiteralSchema, NodeReferenceSchema], + 'Array in flat node must be literal, JSON value, or node reference' + ) + ), + freeze() + ), + JSONLiteralSchema, + LiteralSchema, + NodeReferenceSchema, + null_() + ], + 'Non-array value in flat node must be literal, JSON value, node reference, or null' +); + +type FlatNodeObjectPropertyValue = InferOutput; + +/** + * Schema of JSON-LD node object. + */ +const FlatNodeObjectSchema = pipe( + objectWithRest( + { + '@context': optional(string('Complex @context is not supported in our implementation')), + '@id': IdentifierSchema, + '@type': optional( + union( + [pipe(array(string()), minLength(1), freeze()), string()], + '@type must be string or array of string with at least 1 element' + ) + ) + }, + FlatNodeObjectPropertyValueSchema + ), + freeze() +); + +type FlatNodeObject = InferOutput; + +export { + FlatNodeObjectPropertyValueSchema, + FlatNodeObjectSchema, + type FlatNodeObject, + type FlatNodeObjectPropertyValue +}; diff --git a/packages/core-graph/src/private/schemas/Identifier.spec.ts b/packages/core-graph/src/private/schemas/Identifier.spec.ts new file mode 100644 index 0000000000..95cd396418 --- /dev/null +++ b/packages/core-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 { IdentifierSchema } from './Identifier'; + +scenario('IdentifierSchema', bdd => { + bdd + .given('an empty string', () => '') + .when('parsed', value => safeParse(IdentifierSchema, value)) + .then('should fail', (_, result) => expect(result).toHaveProperty('success', false)); + + bdd + .given('a blank node identifier', () => '_:b1') + .when('parsed', value => safeParse(IdentifierSchema, value)) + .then('should success', (_, result) => expect(result).toHaveProperty('success', true)); + + bdd + .given('a URL', () => 'https://aka.ms') + .when('parsed', value => safeParse(IdentifierSchema, value)) + .then('should success', (_, result) => expect(result).toHaveProperty('success', true)); +}); diff --git a/packages/core-graph/src/private/schemas/Identifier.ts b/packages/core-graph/src/private/schemas/Identifier.ts new file mode 100644 index 0000000000..330a91e03a --- /dev/null +++ b/packages/core-graph/src/private/schemas/Identifier.ts @@ -0,0 +1,22 @@ +import { is, pipe, string, union, url, type GenericSchema, type InferOutput } from 'valibot'; + +import { BlankNodeIdentifierSchema } from './BlankNodeIdentifier'; + +/** + * Schema of JSON-LD identifier (`@id`). Must be either IRI or blank node identifier (prefixed with `_:`). + * + * @see {@link https://www.w3.org/TR/json-ld11/#node-identifiers JSON-LD 1.1: Node Identifiers} + */ +const IdentifierSchema = union( + [ + BlankNodeIdentifierSchema, + pipe(string('Identifier must be a string'), url('Identifier must be an IRI')) as GenericSchema<`https://${string}`> + ], + '@id is required and must be an IRI or blank node identifier' +); + +type Identifier = InferOutput; + +const isIdentifier = is.bind(IdentifierSchema); + +export { IdentifierSchema, isIdentifier, type Identifier }; diff --git a/packages/core-graph/src/private/schemas/JSONLiteral.ts b/packages/core-graph/src/private/schemas/JSONLiteral.ts new file mode 100644 index 0000000000..d25381f86f --- /dev/null +++ b/packages/core-graph/src/private/schemas/JSONLiteral.ts @@ -0,0 +1,24 @@ +import { is, literal, strictObject, unknown, type InferOutput } from 'valibot'; + +/** + * Schema of JSON-LD literals. + * + * @see {@link https://www.w3.org/TR/json-ld11/#dfn-json-literal JSON-LD 1.1: JSON Literals} + */ +const JSONLiteralSchema = strictObject( + { + '@type': literal('@json'), + + // TODO: [P*] Some activities used in tests are not JSON-serializable. + // We are not using JSONValueSchema() until we fix those tests, such as "__tests__/hooks/useUserId.js". + // When fixed, re-enable test in `flattenNodeObject`. + '@value': unknown() + }, + 'JSON literal must only have @type and @value' +); + +type JSONLiteral = InferOutput; + +const isJSONLiteral = is.bind(JSONLiteralSchema); + +export { isJSONLiteral, JSONLiteralSchema, type JSONLiteral }; diff --git a/packages/core-graph/src/private/schemas/JSONValue.ts b/packages/core-graph/src/private/schemas/JSONValue.ts new file mode 100644 index 0000000000..2dbcdedc04 --- /dev/null +++ b/packages/core-graph/src/private/schemas/JSONValue.ts @@ -0,0 +1,56 @@ +import { + array, + boolean, + lazy, + null_, + number, + pipe, + record, + string, + transform, + undefined_, + union, + type GenericSchema +} from 'valibot'; + +type JSONValue = + | string + | number + | boolean + | null + | undefined + | { readonly [key: string]: JSONValue } + | readonly JSONValue[]; + +const JSONValueSchema: GenericSchema = lazy(() => + union( + [ + boolean(), + null_(), + number(), + string(), + array( + union([ + JSONValueSchema, + // In array, transform undefined into null. + // JSON.stringify([1, undefined, 3]) === [1, null, 3] + pipe( + undefined_(), + transform(() => null) + ) + ]) + ), + pipe( + // In object, remove property with value of undefined. + // JSON.stringify({ one: 1, two: undefined, three: 3 }) === { one: 1, three: 3 } + record(string(), union([JSONValueSchema, undefined_()])), + transform(value => + Object.fromEntries(Object.entries(value).filter(([_, value]) => typeof value !== 'undefined')) + ) + ) + ], + 'Only boolean, null, number, string, array, and object is allowed for JSON value' + ) +); + +export { JSONValueSchema, type JSONValue }; diff --git a/packages/core-graph/src/private/schemas/JSONValueSchema.spec.ts b/packages/core-graph/src/private/schemas/JSONValueSchema.spec.ts new file mode 100644 index 0000000000..7d6dae2fc8 --- /dev/null +++ b/packages/core-graph/src/private/schemas/JSONValueSchema.spec.ts @@ -0,0 +1,28 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { parse } from 'valibot'; +import { JSONValueSchema } from './JSONValue'; + +scenario('JSONValueSchema', bdd => { + bdd + .given('a JSON object with value of undefined', () => ({ one: 1, two: undefined, three: 3 })) + .when('parsed', target => parse(JSONValueSchema, target)) + .then('should remove undefined values', (_, result) => expect(result).toEqual({ one: 1, three: 3 })); + + bdd + .given('a JSON array with value of undefined', () => [1, undefined, 3]) + .when('parsed', target => parse(JSONValueSchema, target)) + .then('should transform undefined into null', (_, result) => expect(result).toEqual([1, null, 3])); +}); + +scenario('Assumptions', bdd => { + bdd + .given('a JSON object with value of undefined', () => ({ one: 1, two: undefined, three: 3 })) + .when('stringified then parsed', target => JSON.parse(JSON.stringify(target))) + .then('should remove undefined values', (_, result) => expect(result).toEqual({ one: 1, three: 3 })); + + bdd + .given('a JSON array with value of undefined', () => [1, undefined, 3]) + .when('stringified then parsed', target => JSON.parse(JSON.stringify(target))) + .then('should transform undefined into null', (_, result) => expect(result).toEqual([1, null, 3])); +}); diff --git a/packages/core-graph/src/private/schemas/Literal.ts b/packages/core-graph/src/private/schemas/Literal.ts new file mode 100644 index 0000000000..b395eb57f9 --- /dev/null +++ b/packages/core-graph/src/private/schemas/Literal.ts @@ -0,0 +1,17 @@ +import { boolean, is, number, string, union, type InferOutput } from 'valibot'; + +/** + * Schema of JSON-LD literals. + * + * @see {@link https://www.w3.org/TR/rdf11-concepts/#dfn-literal RDF 1.1 Concepts and Abstract Syntax: Literals} + */ +const LiteralSchema = union( + [boolean(), number(), string()], + 'Only boolean, number, and string are allowed for JSON-LD literal' +); + +type Literal = InferOutput; + +const isLiteral = is.bind(LiteralSchema); + +export { isLiteral, LiteralSchema, type Literal }; diff --git a/packages/core-graph/src/private/schemas/MessageNode.ts b/packages/core-graph/src/private/schemas/MessageNode.ts new file mode 100644 index 0000000000..c726ead761 --- /dev/null +++ b/packages/core-graph/src/private/schemas/MessageNode.ts @@ -0,0 +1,35 @@ +import { freeze } from '@msinternal/botframework-webchat-base/valibot'; +import { + array, + includes, + intersect, + minLength, + object, + optional, + picklist, + pipe, + string, + tuple, + type InferOutput +} from 'valibot'; + +import { DirectLineActivityNodeSchema } from './DirectLineActivityNode'; + +const MessageNodeSchema = pipe( + intersect([ + // TODO: [P*] Not sure why if SlantNode is intersected, the object become frozen and cannot assign @id. + // Related to /html/fluentTheme/maxMessageLength.html. + // SlantNodeSchema, + DirectLineActivityNodeSchema, + object({ + '@type': pipe(array(string()), minLength(1), includes('Message')), + encodingFormat: tuple([picklist(['text/markdown', 'text/plain'])]), + text: optional(tuple([string()])) + }) + ]), + freeze() +); + +type MessageNode = InferOutput; + +export { MessageNodeSchema, type MessageNode }; diff --git a/packages/core-graph/src/private/schemas/NodeReference.ts b/packages/core-graph/src/private/schemas/NodeReference.ts new file mode 100644 index 0000000000..c3b383aa91 --- /dev/null +++ b/packages/core-graph/src/private/schemas/NodeReference.ts @@ -0,0 +1,31 @@ +import { freeze } from '@msinternal/botframework-webchat-base/valibot'; +import { array, is, minLength, optional, pipe, strictObject, string, union, type InferOutput } from 'valibot'; + +import { IdentifierSchema } from './Identifier'; + +/** + * Schema of JSON-LD node reference. A node reference is an object with only `@id`, an optional `@type`, and nothing else. + * + * @see {@link https://www.w3.org/TR/json-ld11/#dfn-node-reference JSON-LD 1.1: Node reference} + */ +const NodeReferenceSchema = pipe( + strictObject( + { + '@id': IdentifierSchema, + '@type': optional( + union( + [string(), pipe(array(string()), minLength(1))], + '@type must be string or array of string with at least 1 element' + ) + ) + }, + 'NodeReference must only have @id and optional @type' + ), + freeze() +); + +type NodeReference = InferOutput; + +const isNodeReference = is.bind(undefined, NodeReferenceSchema); + +export { isNodeReference, NodeReferenceSchema, type NodeReference }; diff --git a/packages/core-graph/src/private/schemas/colorNode.opinion.spec.ts b/packages/core-graph/src/private/schemas/colorNode.opinion.spec.ts new file mode 100644 index 0000000000..9cffa29445 --- /dev/null +++ b/packages/core-graph/src/private/schemas/colorNode.opinion.spec.ts @@ -0,0 +1,327 @@ +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 result[0] === 'undefined') { + expect(result[1]).toEqual(errorOrResult); + } else { + expect(() => { + if (result[0]) { + throw result[0]; + } + }).toThrow(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 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); +}); + +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] }, + 'Only JSON literal, literal, node reference or null can be parsed into slant node' + ] + ], + [ + '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, node reference, or JSON literals', bdd => { + // TODO: Need to move flattenNodeObject into colorNode to test more scenarios in flattening. + // Any array containing `null` is not supported and will throw unless it is JSON literal, as it is likely a bug in code + bdd + .given('an array with null value', () => ({ + '@id': '_:b1', + '@type': 'Message', + attachments: [null] + })) + .when('colored', node => { + try { + colorNode(node as any); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + if (error) { + throw error; + } + }).toThrow('Only JSON literal, literal, node reference or null can be parsed into slant node'); + }); +}); + +scenario("JSON literals will be kept as-is: `{ '@type': '@json', '@value': JSONValue }`", bdd => { + bdd + .given('a JSON literal with a plain object', () => ({ + '@id': '_:b1', + '@type': 'Message', + attachments: { + '@type': '@json', + '@value': { one: 1 } + } + })) + .when('colored', node => colorNode(node as any)) + .then('should return a clean JSON literal', (_, result) => { + expect(result).toEqual({ + '@id': '_:b1', + '@type': ['Message'], + attachments: [ + { + '@type': '@json', + '@value': { one: 1 } + } + ] + }); + }); + + bdd + .given('a JSON literal with an array', () => ({ + '@id': '_:b1', + '@type': 'Message', + attachments: { + '@type': '@json', + '@value': [123] + } + })) + .when('colored', node => colorNode(node as any)) + .then('should return a clean JSON literal', (_, result) => { + expect(result).toEqual({ + '@id': '_:b1', + '@type': ['Message'], + attachments: [ + { + '@type': '@json', + '@value': [123] + } + ] + }); + }); + + bdd + .given('a JSON literal with null', () => ({ + '@id': '_:b1', + '@type': 'Message', + attachments: { + '@type': '@json', + '@value': null + } + })) + .when('colored', node => colorNode(node as any)) + .then('should return a clean JSON literal', (_, result) => { + expect(result).toEqual({ + '@id': '_:b1', + '@type': ['Message'], + attachments: [ + { + '@type': '@json', + '@value': null + } + ] + }); + }); + + bdd + .given('a JSON literal with extraneous properties', () => ({ + '@id': '_:b1', + '@type': 'Message', + attachments: { + '@type': '@json', + '@value': {}, + extraneous: 123 + } + })) + .when('colored', node => { + try { + colorNode(node as any); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + if (error) { + throw error; + } + }).toThrow('Only JSON literal, literal, node reference or null can be parsed into slant node'); + }); +}); + +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'] }, '@context must be an IRI'] + ] + ]) + .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/core-graph/src/private/schemas/colorNode.spec.ts b/packages/core-graph/src/private/schemas/colorNode.spec.ts new file mode 100644 index 0000000000..666e122e5d --- /dev/null +++ b/packages/core-graph/src/private/schemas/colorNode.spec.ts @@ -0,0 +1,126 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; + +import colorNode from './colorNode'; +import type { FlatNodeObject } from './FlatNodeObject'; + +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', + '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 FlatNodeObject + ) + .when('expanded', value => colorNode(value)) + .then('should wrap property values in array', (_, actual) => { + expect(actual).toEqual({ + '@id': '_:b1', + '@type': ['Recipe'], + 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'] + }); + }); + + bdd.given + .oneOf([ + [ + 'a node with hasPart as an array of string', + () => [ + { '@id': '_:b1', '@type': ['Conversation'], hasPart: ['Hello, World!'] }, + 'NodeReference must only have @id and optional @type' + ] + ], + [ + 'a node with hasPart as a string', + () => [ + { '@id': '_:b1', '@type': ['Conversation'], hasPart: 'Hello, World!' }, + 'NodeReference must only have @id and optional @type' + ] + ], + [ + 'a node with isPartOf as an array of string', + () => [ + { '@id': '_:b1', '@type': ['Conversation'], isPartOf: ['Hello, World!'] }, + 'NodeReference must only have @id and optional @type' + ] + ], + [ + 'a node with isPartOf a string', + () => [ + { '@id': '_:b1', '@type': ['Conversation'], isPartOf: 'Hello, World!' }, + 'NodeReference must only have @id and optional @type' + ] + ] + ]) + .when('colored', ([node]) => { + try { + colorNode(node); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', ([_, expectedMessage], error) => { + expect(() => { + throw error; + }).toThrow(expectedMessage); + }); + + bdd + .given( + 'a node with a property value mixed with literal and node reference', + () => + ({ + '@id': '_:b1', + '@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', + '@type': ['Message'], + text: ['Hello, World!', { '@id': '_:b2' }, 0, false] + }); + }); + + bdd + .given('a node with @id of non-IRIs', () => ({ '@id': 'abc' })) + .when('colored', node => { + try { + colorNode(node as any); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + throw error; + }).toThrow('@id is required and must be an IRI or blank node identifier'); + }); +}); diff --git a/packages/core-graph/src/private/schemas/colorNode.ts b/packages/core-graph/src/private/schemas/colorNode.ts new file mode 100644 index 0000000000..96e591d3a7 --- /dev/null +++ b/packages/core-graph/src/private/schemas/colorNode.ts @@ -0,0 +1,160 @@ +import { freeze } from '@msinternal/botframework-webchat-base/valibot'; +import { + array, + looseObject, + minLength, + null_, + objectWithRest, + optional, + parse, + pipe, + string, + transform, + union, + type InferOutput, + type ObjectSchema +} from 'valibot'; + +import type { FlatNodeObject } from './FlatNodeObject'; +import { IdentifierSchema } from './Identifier'; +import { JSONLiteralSchema, type JSONLiteral } from './JSONLiteral'; +import { LiteralSchema, type Literal } from './Literal'; +import { NodeReferenceSchema, type NodeReference } from './NodeReference'; + +// Our opinions. +const SlantNodeSchema = pipe( + objectWithRest( + { + // We treat @context as opaque string than a schema. + '@context': optional(string('@context must be an IRI')), + '@id': IdentifierSchema, + // Multi-membership is enabled by default. + '@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". + hasPart: optional( + pipe( + array(NodeReferenceSchema, 'hasPart must be array of NodeReference'), + 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. + // This relationship is "membership" than "hierarchy". + isPartOf: optional( + pipe( + array(NodeReferenceSchema, 'isPartOf must be array of NodeReference'), + freeze(), + minLength(1, 'isPartOf, if present, must have at least one element') + ) + ) + }, + // The rest property values must be encapsulated in array. + // Array of boolean, number, string, JSON literal, and node reference are accepted. + pipe( + array( + union( + [JSONLiteralSchema, LiteralSchema, NodeReferenceSchema], + 'Properties of slant node must be array of JSON literal, literal or node reference' + ) + ), + minLength(1, 'Properties of slant node must be an array with at least 1 element') + ) + ), + freeze() +); + +// Due to limitation on TypeScript, we cannot truthfully represent the typing. +type SlantNode = InferOutput> & { + [key: string]: unknown; +}; + +const InputValueSchema = union( + [ + array(union([JSONLiteralSchema, LiteralSchema, NodeReferenceSchema])), + JSONLiteralSchema, + LiteralSchema, + NodeReferenceSchema, + null_() + ], + 'Only JSON literal, literal, node reference or null can be parsed into slant node' +); + +const SlantNodeWithFixSchema = pipe( + looseObject({}), + transform(node => { + const propertyMap = new Map(); + let context: string | undefined; + let id: string | undefined; + + for (const [key, value] of Object.entries(node)) { + switch (key) { + case '@context': + context = parse(string('@context must be an IRI'), value); + break; + + case '@id': + id = parse(IdentifierSchema, value); + break; + + default: { + const parsedValue = parse(InputValueSchema, value); + + const slantedValue = Object.freeze( + Array.isArray(parsedValue) + ? parsedValue.slice(0) + : parsedValue === null || typeof parsedValue === 'undefined' + ? [] + : [parsedValue] + ); + + slantedValue.length && propertyMap.set(key, slantedValue); + + break; + } + } + } + + return parse( + SlantNodeSchema, + Object.fromEntries([...(context ? [['@context', context]] : []), ['@id', id], ...Array.from(propertyMap)]) + ); + }) +); + +/** + * Put our opinions into the node. + * + * The opinions are targeted around a few principles: + * + * - 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` + * - 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: 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, node reference, or JSON literals + * - Any array containing `null` is not supported and will throw unless it is JSON literal, as it is likely a bug in code + * - JSON literals will have boxing kept: `{ '@type': '@json', '@value': JSONValue }` + * - `@value` could be null, if unwrapped, will be confusing as we removed nulls + * - Do not handle full JSON-LD spec: `@context` is an opaque string and its schema is not honored + * - Node reference only has `@id` and it should not contain `@type` + * - Reduce confusion: node reference must not appear at the root of the flattened graph, they are semantically empty + * - 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): SlantNode { + return parse(SlantNodeWithFixSchema, node); +} + +export default colorNode; +export { SlantNodeSchema, SlantNodeWithFixSchema, type SlantNode }; diff --git a/packages/core-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts b/packages/core-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts new file mode 100644 index 0000000000..25dc2a19e5 --- /dev/null +++ b/packages/core-graph/src/private/schemas/flattenNodeObject.conversation.spec.ts @@ -0,0 +1,212 @@ +import { expect, jest } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; + +import flattenNodeObject from './flattenNodeObject'; +import { IdentifierSchema } from './Identifier'; +import { NodeReferenceSchema } from './NodeReference'; +import './private/expectExtendValibot'; + +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(IdentifierSchema), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(NodeReferenceSchema), expect.valibot(NodeReferenceSchema)] + }); + }) + .and('should return 7 objects', (_, { graph }) => { + expect(graph).toEqual([ + { + '@context': 'https://schema.org/', + '@id': expect.valibot(IdentifierSchema), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(NodeReferenceSchema), expect.valibot(NodeReferenceSchema)] + }, + { + '@id': expect.valibot(IdentifierSchema), + '@type': 'Message', + sender: { '@id': '_:bugs-bunny' }, + recipient: { '@id': '_:daffy-duck' }, + about: expect.valibot(NodeReferenceSchema), + datePublished: '2016-02-29' + }, + { '@id': '_:bugs-bunny', '@type': 'Person', name: 'Bugs Bunny' }, + { '@id': '_:daffy-duck', '@type': 'Person', name: 'Daffy Duck' }, + { + '@id': expect.valibot(IdentifierSchema), + '@type': 'Thing', + name: 'Duck Season' + }, + { + '@id': expect.valibot(IdentifierSchema), + '@type': 'Message', + sender: { '@id': '_:daffy-duck' }, + recipient: { '@id': '_:bugs-bunny' }, + about: expect.valibot(NodeReferenceSchema), + datePublished: '2016-03-01' + }, + { + '@id': expect.valibot(IdentifierSchema), + '@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(IdentifierSchema), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(NodeReferenceSchema), expect.valibot(NodeReferenceSchema)] + }); + }) + .and('should return 7 objects', (_, { graph }) => { + expect(graph).toEqual([ + { + '@context': 'https://schema.org/', + '@id': expect.valibot(IdentifierSchema), + '@type': 'Conversation', + name: 'Duck Season vs Rabbit Season', + sameAs: 'https://www.youtube.com/watch?v=9-k5J4RxQdE', + hasPart: [expect.valibot(NodeReferenceSchema), expect.valibot(NodeReferenceSchema)] + }, + { + '@id': expect.valibot(IdentifierSchema), + '@type': 'Message', + sender: expect.valibot(NodeReferenceSchema), + recipient: expect.valibot(NodeReferenceSchema), + about: { '@id': expect.valibot(IdentifierSchema) }, + datePublished: '2016-02-29' + }, + { '@id': expect.valibot(IdentifierSchema), '@type': 'Person', name: 'Bugs Bunny' }, + { '@id': expect.valibot(IdentifierSchema), '@type': 'Person', name: 'Daffy Duck' }, + { + '@id': expect.valibot(IdentifierSchema), + '@type': 'Thing', + name: 'Duck Season' + }, + { + '@id': expect.valibot(IdentifierSchema), + '@type': 'Message', + sender: expect.valibot(NodeReferenceSchema), + recipient: expect.valibot(NodeReferenceSchema), + about: expect.valibot(NodeReferenceSchema), + datePublished: '2016-03-01' + }, + { + '@id': expect.valibot(IdentifierSchema), + '@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/core-graph/src/private/schemas/flattenNodeObject.spec.ts b/packages/core-graph/src/private/schemas/flattenNodeObject.spec.ts new file mode 100644 index 0000000000..10e1adb702 --- /dev/null +++ b/packages/core-graph/src/private/schemas/flattenNodeObject.spec.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, expect, jest } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import { array, assert, length, object, pipe } from 'valibot'; + +import { BlankNodeIdentifierSchema } from './BlankNodeIdentifier'; +import { FlatNodeObjectSchema } from './FlatNodeObject'; +import flattenNodeObject from './flattenNodeObject'; +import { IdentifierSchema } from './Identifier'; +import type { Literal } from './Literal'; +import { NodeReferenceSchema } from './NodeReference'; +import './private/expectExtendValibot'; + +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([ + [ + '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 }] + ] + ]) + .when('flattened', ([value]) => flattenNodeObject(value)) + .then('should return a node reference', (_, { output }) => { + assert(NodeReferenceSchema, output); + assert(BlankNodeIdentifierSchema, output['@id']); + }) + .and('should return a graph with one node object', (_, { graph }) => { + assert(pipe(array(FlatNodeObjectSchema), 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(IdentifierSchema), + description: 'The Empire State Building is a 102-story landmark in New York City.', + geo: expect.valibot(NodeReferenceSchema), + image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', + name: 'The Empire State Building' + }, + { + '@id': expect.valibot(IdentifierSchema), + 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: NodeReferenceSchema }), rootObject); + + const geoObject = graph.find(object => object !== rootObject); + + assert(FlatNodeObjectSchema, geoObject); + + expect(rootObject.geo['@id']).toBe(geoObject['@id']); + }); + + 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. + ['an undefined value', () => undefined] // Undefined cannot be flattened, only null is allowed. + ]) + .when('catching exception from the call', (value): any => { + try { + flattenNodeObject(value as any); + } catch (error) { + return error; + } + }) + .then('should throw', (_, error) => expect(error).toBeTruthy()); + + bdd + .given(`an object with an array mixed of JSON literal and plain object`, () => ({ + description: 'The Empire State Building is a 102-story landmark in New York City.', + geo: [ + { + '@type': '@json', + '@value': { + latitude: '40.75', + longitude: '73.98' + } + }, + { + city: 'New York', + state: 'NY', + street: '20 West 34th Street', + zipCode: '10118' + } + ], + 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 1 node object', (_, { graph }) => { + expect(graph).toEqual([ + { + '@id': expect.valibot(IdentifierSchema), + description: 'The Empire State Building is a 102-story landmark in New York City.', + // "geo" property should kept as-is. + geo: [ + { + '@type': '@json', + '@value': { + latitude: '40.75', + longitude: '73.98' + } + }, + expect.valibot(NodeReferenceSchema) + ], + image: 'http://www.civil.usherbrooke.ca/cours/gci215a/empire-state-building.jpg', + name: 'The Empire State Building' + }, + { + '@id': expect.valibot(IdentifierSchema), + city: 'New York', + state: 'NY', + street: '20 West 34th Street', + zipCode: '10118' + } + ]); + }); + + // TODO: [P0] Skipping this test as we temporarily allow `unknown` to be stored inside `@value`. + // Related to `JSONLiteral.ts`. + // bdd + // .given(`a class object with @type of '@json'`, () => ({ + // '@id': '_:b1', + // value: { + // '@type': '@json', + // '@value': Symbol() + // } + // })) + // .when('flattened', value => { + // try { + // flattenNodeObject(value); + // } catch (error) { + // return error; + // } + + // return undefined; + // }) + // .then('should throw', (_, error) => { + // expect(() => { + // if (error) { + // throw error; + // } + // }).toThrow('Only literals, JSON literals, and plain object can be flattened'); + // }); +}); + +scenario( + 'Reduce confusion: node reference must not appear at the root of the flattened graph, they are semantically empty', + bdd => { + bdd + .given( + 'a node with @id and @type only', + () => + ({ + '@id': '_:c1', + '@type': 'Conversation' + }) as const + ) + .when('colored', node => { + try { + flattenNodeObject(node); + } catch (error) { + return error; + } + + return undefined; + }) + .then('should throw', (_, error) => { + expect(() => { + if (error) { + throw error; + } + }).toThrow('Node reference cannot be flattened'); + }); + } +); diff --git a/packages/core-graph/src/private/schemas/flattenNodeObject.ts b/packages/core-graph/src/private/schemas/flattenNodeObject.ts new file mode 100644 index 0000000000..1b9d805276 --- /dev/null +++ b/packages/core-graph/src/private/schemas/flattenNodeObject.ts @@ -0,0 +1,183 @@ +// TODO: [P0] This flattening can probably fold into `colorNode()` as it has slanted view of the system. + +import { v4 } from 'uuid'; +import { assert, check, looseObject, object, optional, parse, pipe, safeParse } from 'valibot'; + +import { FlatNodeObjectSchema, type FlatNodeObject, type FlatNodeObjectPropertyValue } from './FlatNodeObject'; +import { IdentifierSchema, type Identifier } from './Identifier'; +import { JSONLiteralSchema, type JSONLiteral } from './JSONLiteral'; +import { LiteralSchema, type Literal } from './Literal'; +import { isNodeReference, NodeReferenceSchema, type NodeReference } from './NodeReference'; +import isPlainObject from './private/isPlainObject'; + +function randomUUID(): string { + // crypto.randomUUID() requires HTTPS context. + // However, our legacy Jest tests are not running over HTTPS. + return v4(); +} + +type FlattenNodeObjectInput = Literal | (object & { '@id'?: string }); + +function flattenNodeObject_( + input: T, + graphMap: Map, + refMap: Map +): T; + +function flattenNodeObject_( + input: FlattenNodeObjectInput, + graphMap: Map, + refMap: Map +): NodeReference; + +function flattenNodeObject_( + input: JSONLiteral, + graphMap: Map, + refMap: Map +): JSONLiteral; + +function flattenNodeObject_( + input: FlattenNodeObjectInput | Literal, + graphMap: Map, + refMap: Map +): JSONLiteral | Literal | NodeReference; + +function flattenNodeObject_( + input: FlattenNodeObjectInput | Literal, + graphMap: Map, + refMap: Map +): JSONLiteral | Literal | NodeReference { + const parseAsLiteralResult = safeParse(LiteralSchema, input); + + if (parseAsLiteralResult.success) { + return parseAsLiteralResult.output; + } + + const parseAsJSONLiteralResult = safeParse( + pipe( + JSONLiteralSchema, + check(value => isPlainObject(value)) + ), + input + ); + + if (parseAsJSONLiteralResult.success) { + return parseAsJSONLiteralResult.output; + } + + const parseAsNodeReferenceResult = safeParse(NodeReferenceSchema, input); + + if (parseAsNodeReferenceResult.success) { + return parseAsNodeReferenceResult.output; + } + + // This is for TypeScript only because safeParse().success is not a type predicate. + input = input as object; + + // Array is allowed by valibot.object({}), we need to check for plain object first. + if (!isPlainObject(input) || !safeParse(object({}), input).success) { + // TODO: [P0] For "undefined", maybe we just want to remove it or just set it to null. + // Or we should consolidate `colorNode` here as `colorNode` will handle that. + const error = new Error( + `Only literals, JSON literals, and plain object can be flattened: ${JSON.stringify(input)}` + ); + + error.cause = { input }; + + throw error; + } + + const existingObjectReference = refMap.get(input); + + if (existingObjectReference) { + return existingObjectReference; + } + + const id = + parse( + optional(IdentifierSchema), + (input && typeof input === 'object' && '@id' in input && input['@id']) || undefined + ) ?? `_:${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: FlatNodeObjectPropertyValue; + + if (Array.isArray(value)) { + const resultArray: (JSONLiteral | Literal | NodeReference)[] = []; + + for (const element of value) { + resultArray.push(flattenNodeObject_(element, graphMap, refMap)); + } + + parsedValue = Object.freeze(resultArray); + + targetMap.set(key, parsedValue); + } else if (typeof value !== 'undefined') { + parsedValue = flattenNodeObject_(value, graphMap, refMap); + + targetMap.set(key, parsedValue); + } + } + + targetMap.set('@id', id); + + const output: FlatNodeObject = parse(FlatNodeObjectSchema, Object.fromEntries(Array.from(targetMap))); + const nodeRef = parse(NodeReferenceSchema, Object.freeze({ '@id': id })); + + graphMap.set(id, output); + refMap.set(input, nodeRef); + + return nodeRef; +} + +type FlattenNodeObjectReturnValue = { + /** A graph consists of one or more objects. */ + readonly graph: readonly FlatNodeObject[]; + /** 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: + * + * - 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`. + * - 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. + */ +function flattenNodeObject(input: FlattenNodeObjectInput): FlattenNodeObjectReturnValue { + assert( + pipe( + looseObject({}), + check(value => !isNodeReference(value), 'Node reference cannot be flattened') + ), + input + ); + + const graph = new Map(); + const refMap = new Map(); + const output = flattenNodeObject_(input, graph, refMap); + + return { graph: Object.freeze(Array.from(graph.values())), output }; +} + +export default flattenNodeObject; +export { type FlattenNodeObjectInput, type FlattenNodeObjectReturnValue }; diff --git a/packages/core-graph/src/private/schemas/isOfType.spec.ts b/packages/core-graph/src/private/schemas/isOfType.spec.ts new file mode 100644 index 0000000000..ac789ac344 --- /dev/null +++ b/packages/core-graph/src/private/schemas/isOfType.spec.ts @@ -0,0 +1,34 @@ +import { expect } from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import type { FlatNodeObject } from './FlatNodeObject'; +import isOfType from './isOfType'; + +scenario('isOfType', bdd => { + bdd + .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]] + ]) + .then('should return expected value', (_, [actual, expected]) => { + expect(actual).toBe(expected); + }); + + bdd + .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]], + ['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 FlatNodeObject) + .when('isOfType() is called with "Message"', value => [isOfType(value as any, 'Message'), false]) + .then('should return expected false', (_, [actual, expected]) => { + expect(actual).toBe(expected); + }); +}); diff --git a/packages/core-graph/src/private/schemas/isOfType.ts b/packages/core-graph/src/private/schemas/isOfType.ts new file mode 100644 index 0000000000..5e4cbdf740 --- /dev/null +++ b/packages/core-graph/src/private/schemas/isOfType.ts @@ -0,0 +1,5 @@ +export default function isOfType(nodeObject: { '@type': readonly string[] | string }, type: string): boolean { + const types = nodeObject['@type']; + + return typeof types === 'string' ? types === type : !!types && types.includes(type); +} diff --git a/packages/core-graph/src/private/schemas/private/expectExtendValibot.ts b/packages/core-graph/src/private/schemas/private/expectExtendValibot.ts new file mode 100644 index 0000000000..558c26475d --- /dev/null +++ b/packages/core-graph/src/private/schemas/private/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/core-graph/src/private/schemas/private/expectIsFrozen.ts b/packages/core-graph/src/private/schemas/private/expectIsFrozen.ts new file mode 100644 index 0000000000..8c0b27a60a --- /dev/null +++ b/packages/core-graph/src/private/schemas/private/expectIsFrozen.ts @@ -0,0 +1,17 @@ +import { expect } from '@jest/globals'; + +declare module 'expect' { + interface AsymmetricMatchers { + isFrozen(): any; + } +} + +expect.extend({ + isFrozen: actual => + Object.isFrozen(actual) + ? { message: () => '', pass: true } + : { + message: () => 'object is not frozen', + pass: false + } +}); diff --git a/packages/core-graph/src/private/schemas/private/isPlainObject.ts b/packages/core-graph/src/private/schemas/private/isPlainObject.ts new file mode 100644 index 0000000000..6594bf3b0a --- /dev/null +++ b/packages/core-graph/src/private/schemas/private/isPlainObject.ts @@ -0,0 +1,3 @@ +export default function isPlainObject(input: unknown): input is object { + return Object.prototype.toString.call(input) === '[object Object]'; +} diff --git a/packages/core-graph/src/tsconfig.json b/packages/core-graph/src/tsconfig.json new file mode 100644 index 0000000000..d0fab377e4 --- /dev/null +++ b/packages/core-graph/src/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@msinternal/botframework-webchat-tsconfig/current" +} diff --git a/packages/core-graph/tsup.config.ts b/packages/core-graph/tsup.config.ts new file mode 100644 index 0000000000..66ee141fe4 --- /dev/null +++ b/packages/core-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-core-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/core/graph.js b/packages/core/graph.js new file mode 100644 index 0000000000..f6ca2f9abd --- /dev/null +++ b/packages/core/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-core.graph.js'); diff --git a/packages/core/package.json b/packages/core/package.json index 278b583fdf..77214260e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,16 @@ "default": "./dist/botframework-webchat-core.js" } }, + "./graph": { + "import": { + "types": "./dist/botframework-webchat-core.graph.d.mts", + "default": "./dist/botframework-webchat-core.graph.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-core.graph.d.ts", + "default": "./dist/botframework-webchat-core.graph.js" + } + }, "./internal": { "import": { "types": "./dist/botframework-webchat-core.internal.d.mts", @@ -74,7 +84,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\" \"../tsconfig/package.json\"", + "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\" \"../core-graph/package.json\"", "test:tsd": "tsd" }, "engines": { @@ -84,10 +94,15 @@ "typescript": [ "~5.8.3", "@typescript-eslint/parser@8.38.0 does not support typescript@5.9.2 yet" + ], + "uuid": [ + "8", + "uuid@9 emit non-ES5 build because of default parameters" ] }, "localDependencies": { "@msinternal/botframework-webchat-base": "development", + "@msinternal/botframework-webchat-core-graph": "development", "@msinternal/botframework-webchat-tsconfig": "development" }, "devDependencies": { @@ -96,6 +111,7 @@ "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-env": "^7.28.0", "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-core-graph": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", "@types/jest": "^30.0.0", "@types/node": "^24.1.0", @@ -110,13 +126,15 @@ "dependencies": { "@babel/runtime": "7.28.2", "@redux-devtools/extension": "3.3.0", + "core-js-pure": "3.44.0", + "iter-fest": "0.3.0", "jwt-decode": "4.0.0", "math-random": "2.0.1", "mime": "4.0.7", "redux": "5.0.1", "redux-saga": "1.3.0", "simple-update-in": "2.2.0", + "uuid": "8.3.2", "valibot": "1.1.0" - }, - "peerDependencies": {} + } } diff --git a/packages/core/src/graph/createGraphFromStore.ts b/packages/core/src/graph/createGraphFromStore.ts new file mode 100644 index 0000000000..2eb1c102be --- /dev/null +++ b/packages/core/src/graph/createGraphFromStore.ts @@ -0,0 +1,122 @@ +import { SlantGraph, SlantNodeSchema } from '@msinternal/botframework-webchat-core-graph'; +import type { IterableElement } from 'type-fest'; +import { parse } from 'valibot'; +import type createStore from '../createStore'; +import type createActivitiesReducer from '../reducers/createActivitiesReducer'; + +type Activity = IterableElement>>; + +function createGraphFromStore(store: ReturnType): SlantGraph { + const graph = new SlantGraph(); + let prevActivities: readonly Activity[] | undefined; + + // TODO: [P0] Except channel audience, we should be specific about all audience, e.g. name. + const channelAudience = parse(SlantNodeSchema, { + '@context': 'https://schema.org', + '@id': '_:audience/channel', + '@type': ['Audience'], + audienceType: ['channel'] + }); + + const othersAudience = parse(SlantNodeSchema, { + '@context': 'https://schema.org', + '@id': '_:audience/others', + '@type': ['Audience'], + audienceType: ['others'] + }); + + const selfAudience = parse(SlantNodeSchema, { + '@context': 'https://schema.org', + '@id': '_:audience/user', + '@type': ['Audience'], + audienceType: ['self'] + }); + + graph.act(graph => graph.upsert(othersAudience, channelAudience, selfAudience)); + + store.subscribe(() => { + const { activities }: { activities: readonly Activity[] } = store.getState(); + + if (Object.is(activities, prevActivities)) { + return; + } + + const activitySet = new Set(activities); + const prevActivitySet = new Set(prevActivities); + + // TODO: [P*] Supports deleting node from the graph. + const addedActivities = activitySet.difference(prevActivitySet); + + graph.act(graph => { + for (const activity of addedActivities) { + const { + from: { role } + } = activity; + + const permanentId = activity.channelData['webchat:internal:id']; + const position = activity.channelData['webchat:internal:position']; + + // TODO: Should use Person and more specific than just "Others". + const sender = + role === 'bot' + ? { '@id': othersAudience['@id'] } + : role === 'user' + ? { '@id': selfAudience['@id'] } + : role === 'channel' + ? { '@id': channelAudience['@id'] } + : undefined; + + if (activity.type === 'message' || activity.type === 'typing') { + // TODO: [P*] If this is livestreaming, add isPartOf to indicate the livestream head. + graph.upsert({ + '@context': 'https://schema.org', + + '@id': `_:${permanentId}`, + '@type': ['Message', `urn:microsoft:webchat:direct-line-activity`], + + encodingFormat: + 'textFormat' in activity && activity.textFormat !== 'markdown' ? 'text/plain' : 'text/markdown', + + // TODO: [P0] activity.id could be null here. + // TODO: [P0] Not sure if we need client activity ID here as we already have permanent ID. + identifier: [ + ...(activity.id ? [`urn:microsoft:webchat:direct-line-activity:id:${activity.id}`] : []), + ...(typeof activity.channelData.clientActivityID === 'string' + ? [`urn:microsoft:webchat:client-activity-id:${activity.channelData.clientActivityID}`] + : []) + ], + + position, + sender, + text: ('text' in activity && typeof activity.text === 'string' && activity.text) || undefined, + + 'urn:microsoft:webchat:direct-line-activity:raw-json': { '@type': '@json', '@value': activity }, + 'urn:microsoft:webchat:direct-line-activity:type': activity.type + }); + } else if (typeof activity.type === 'string') { + graph.upsert({ + '@context': 'https://schema.org', + '@id': `_:${permanentId}`, + '@type': Object.freeze(['urn:microsoft:webchat:direct-line-activity']), + identifier: activity.id && `urn:microsoft:webchat:direct-line-activity:id:${activity.id}`, + position, + sender, + 'urn:microsoft:webchat:direct-line-activity:raw-json': { '@type': '@json', '@value': activity }, + 'urn:microsoft:webchat:direct-line-activity:type': activity.type + }); + } else { + console.warn( + `botframework-webchat: Activity must have "type" with value of string, ignoring activity without proper "type" field.`, + { activity } + ); + } + } + }); + + prevActivities = activities; + }); + + return graph; +} + +export default createGraphFromStore; diff --git a/packages/core/src/graph/index.ts b/packages/core/src/graph/index.ts new file mode 100644 index 0000000000..f7b59f50d8 --- /dev/null +++ b/packages/core/src/graph/index.ts @@ -0,0 +1,18 @@ +export { + IdentifierSchema, + isOfType, + MessageNodeSchema, + SlantGraph, + SlantNodeSchema, + type GraphMiddleware, + type GraphNode, + type GraphState, + type GraphSubscriber, + type GraphSubscriberRecord, + type Identifier, + type MessageNode, + type ReadableGraph, + type SlantNode, + type WritableGraph +} from '@msinternal/botframework-webchat-core-graph'; +export { default as createGraphFromStore } from './createGraphFromStore'; diff --git a/packages/core/src/reducers/createActivitiesReducer.ts b/packages/core/src/reducers/createActivitiesReducer.ts index 531c57c5a1..0481600dd1 100644 --- a/packages/core/src/reducers/createActivitiesReducer.ts +++ b/packages/core/src/reducers/createActivitiesReducer.ts @@ -1,6 +1,7 @@ /* eslint no-magic-numbers: ["error", { "ignore": [0, 1, -1] }] */ import updateIn from 'simple-update-in'; +import { v4 } from 'uuid'; import { DELETE_ACTIVITY } from '../actions/deleteActivity'; import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; @@ -14,7 +15,6 @@ import { import { SENDING, SEND_FAILED, SENT } from '../types/internal/SendStatus'; import getActivityLivestreamingMetadata from '../utils/getActivityLivestreamingMetadata'; import getOrgSchemaMessage from '../utils/getOrgSchemaMessage'; -import findBeforeAfter from './private/findBeforeAfter'; import type { Reducer } from 'redux'; import type { DeleteActivityAction } from '../actions/deleteActivity'; @@ -52,11 +52,38 @@ function findByClientActivityID(clientActivityID: string): (activity: WebChatAct return (activity: WebChatActivity) => getClientActivityID(activity) === clientActivityID; } -function patchActivity( +/** + * Get sequence ID from `activity.channelData['webchat:sequence-id']` and fallback to `+new Date(activity.timestamp)`. + * + * Chat adapter may send sequence ID to affect activity reordering. Sequence ID is supposed to be Unix timestamp. + * + * @param activity Activity to get sequence ID from. + * @returns Sequence ID. + */ +function getSequenceIdOrDeriveFromTimestamp( activity: WebChatActivity, - activities: WebChatActivity[], - { Date }: GlobalScopePonyfill -): WebChatActivity { + ponyfill: Pick +): number | undefined { + const sequenceId = activity.channelData?.['webchat:sequence-id']; + + if (typeof sequenceId === 'number') { + return sequenceId; + } + + const { timestamp } = activity; + + if (typeof timestamp === 'string') { + return +new ponyfill.Date(timestamp); + } else if ((timestamp as any) instanceof ponyfill.Date) { + console.warn('botframework-webchat: "timestamp" must be of type string, instead of Date.'); + + return +timestamp; + } + + return undefined; +} + +function patchActivity(activity: WebChatActivity, { Date }: GlobalScopePonyfill): WebChatActivity { // Direct Line channel will return a placeholder image for the user-uploaded image. // As observed, the URL for the placeholder image is https://docs.botframework.com/static/devportal/client/images/bot-framework-default-placeholder.png. // To make our code simpler, we are removing the value if "contentUrl" is pointing to a placeholder image. @@ -79,63 +106,10 @@ function patchActivity( const entityPosition = messageEntity?.position; const entityPartOf = messageEntity?.isPartOf?.['@id']; - const { - channelData: { 'webchat:sequence-id': sequenceId } - } = activity; - - // TODO: [P1] #3953 We should move this patching logic to a DLJS wrapper for simplicity. - // If the message does not have sequence ID, use these fallback values: - // 1. "channelData.streamSequence" field (if available) - // - 0.0001 * streamSequence should be good - // 2. "timestamp" field - // - outgoing activity will not have "timestamp" field - // 3. last activity sequence ID (or 0) + 0.001 - // - best effort to put this message the last one in the chat history - if (typeof sequenceId !== 'number') { - let after: WebChatActivity; - let before: WebChatActivity; - const metadata = getActivityLivestreamingMetadata(activity); - - if (metadata) { - [before, after] = findBeforeAfter(activities, target => { - const targetMetadata = getActivityLivestreamingMetadata(target); - - if (targetMetadata?.sessionId === metadata.sessionId) { - return targetMetadata.sequenceNumber < metadata.sequenceNumber ? 'before' : 'after'; - } - - return 'unknown'; - }); - } - - let sequenceId: number; - - if (before) { - if (after) { - // eslint-disable-next-line no-magic-numbers - sequenceId = (before.channelData['webchat:sequence-id'] + after.channelData['webchat:sequence-id']) / 2; - } else { - // eslint-disable-next-line no-magic-numbers - sequenceId = before.channelData['webchat:sequence-id'] + 0.001; - } - } else if (after) { - // eslint-disable-next-line no-magic-numbers - sequenceId = after.channelData['webchat:sequence-id'] - 0.001; - } else if (typeof activity.timestamp !== 'undefined') { - sequenceId = +new Date(activity.timestamp); - } else { - // We assume there will be no more than 1,000 messages sent before receiving server response. - // If there are more than 1,000 messages, some messages will get reordered and appear jumpy after receiving server response. - // eslint-disable-next-line no-magic-numbers - sequenceId = (activities[activities.length - 1]?.channelData['webchat:sequence-id'] || 0) + 0.001; - } - - activity = updateIn(activity, ['channelData', 'webchat:sequence-id'], () => sequenceId); - } - if (typeof entityPosition === 'number') { activity = updateIn(activity, ['channelData', 'webchat:entity-position'], () => entityPosition); } + if (typeof entityPartOf === 'string') { activity = updateIn(activity, ['channelData', 'webchat:entity-part-of'], () => entityPartOf); } @@ -145,13 +119,14 @@ function patchActivity( function upsertActivityWithSort( activities: WebChatActivity[], - nextActivity: WebChatActivity, + upsertingActivity: WebChatActivity, ponyfill: GlobalScopePonyfill ): WebChatActivity[] { - const metadata = getActivityLivestreamingMetadata(nextActivity); + const upsertingLivestreamingMetadata = getActivityLivestreamingMetadata(upsertingActivity); - if (metadata) { - const { sessionId } = metadata; + // TODO: [P1] To support time-travelling, we should not drop obsoleted livestreaming activities. + if (upsertingLivestreamingMetadata) { + const { sessionId } = upsertingLivestreamingMetadata; // If the upserting activity is going upsert into a concluded livestream, skip the activity. const isLivestreamConcluded = activities.find(targetActivity => { @@ -165,40 +140,113 @@ function upsertActivityWithSort( } } - nextActivity = patchActivity(nextActivity, activities, ponyfill); + upsertingActivity = patchActivity(upsertingActivity, ponyfill); - const { channelData: { clientActivityID: nextClientActivityID, 'webchat:sequence-id': nextSequenceId } = {} } = - nextActivity; + const { channelData: { clientActivityID: upsertingClientActivityID } = {} } = upsertingActivity; const nextActivities = activities.filter( ({ channelData: { clientActivityID } = {}, id }) => // We will remove all "sending messages" activities and activities with same ID // "clientActivityID" is unique and used to track if the message has been sent and echoed back from the server - !(nextClientActivityID && clientActivityID === nextClientActivityID) && !(id && id === nextActivity.id) + !(upsertingClientActivityID && clientActivityID === upsertingClientActivityID) && + !(id && id === upsertingActivity.id) ); - const nextEntityPosition = nextActivity.channelData?.['webchat:entity-position']; - const nextPartOf = nextActivity.channelData?.['webchat:entity-part-of']; + const upsertingEntityPosition = upsertingActivity.channelData?.['webchat:entity-position']; + const upsertingPartOf = upsertingActivity.channelData?.['webchat:entity-part-of']; + const upsertingSequenceId = getSequenceIdOrDeriveFromTimestamp(upsertingActivity, ponyfill); - const indexToInsert = nextActivities.findIndex(({ channelData = {} }) => { - const currentSequenceId = channelData['webchat:sequence-id'] || 0; - const currentPosition = channelData['webchat:entity-position']; - const currentPartOf = channelData['webchat:entity-part-of']; + // TODO: [P1] #3953 We should move this patching logic to a DLJS wrapper for simplicity. + // If the message does not have sequence ID, use these fallback values: + // 1. `entities.position` where `entities.isPartOf[@type === 'HowTo']` + // - If they are not of same set, ignore `entities.position` + // 2. `channelData.streamSequence` field for same session IDk + // 3. `channelData['webchat:sequence-id']` + // - If not available, it will fallback to `+new Date(timestamp)` + // - Outgoing activity will not have `timestamp` field + + let indexToInsert = nextActivities.findIndex(activity => { + const { channelData = {} } = activity; + const currentEntityPosition = channelData['webchat:entity-position']; + const currentEntityPartOf = channelData['webchat:entity-part-of']; - const bothHavePosition = typeof currentPosition === 'number' && typeof nextEntityPosition === 'number'; - const bothArePartOf = typeof currentPartOf === 'string' && currentPartOf === nextPartOf; + const bothHavePosition = typeof currentEntityPosition === 'number' && typeof upsertingEntityPosition === 'number'; + const bothArePartOf = typeof currentEntityPartOf === 'string' && currentEntityPartOf === upsertingPartOf; // For activities in the same creative work part, position is primary sort key if (bothHavePosition && bothArePartOf) { - return currentPosition > nextEntityPosition; + return currentEntityPosition > upsertingEntityPosition; } - // For activities not in the same part or without positions follow sequence ID order - return (currentSequenceId || 0) > (nextSequenceId || 0); + return false; }); - // If no right place are found, append it - nextActivities.splice(~indexToInsert ? indexToInsert : nextActivities.length, 0, nextActivity); + if (!~indexToInsert) { + indexToInsert = nextActivities.findIndex(activity => { + const currentLivestreamingMetadata = getActivityLivestreamingMetadata(activity); + + if ( + upsertingLivestreamingMetadata && + currentLivestreamingMetadata && + upsertingLivestreamingMetadata.sessionId === currentLivestreamingMetadata.sessionId + ) { + return currentLivestreamingMetadata.sequenceNumber > upsertingLivestreamingMetadata.sequenceNumber; + } + + return false; + }); + } + + // If the upserting activity does not have sequence ID or timestamp, always append it. + if (!~indexToInsert && typeof upsertingSequenceId === 'number') { + indexToInsert = nextActivities.findIndex(activity => { + const currentSequenceId = getSequenceIdOrDeriveFromTimestamp(activity, ponyfill); + + if (typeof currentSequenceId === 'undefined') { + // Treat messages without sequence ID and timestamp as hidden/opaque. Don't use them to influence ordering. + // Related to /__tests__/html2/activityOrdering/mixingMessagesWithAndWithoutTimestamp.html. + return false; + } + + return currentSequenceId > upsertingSequenceId; + }); + } + + if (!~indexToInsert) { + // If no right place can be found, append it. + indexToInsert = nextActivities.length; + } + + const prevSibling: WebChatActivity = indexToInsert === 0 ? undefined : nextActivities.at(indexToInsert - 1); + const nextSibling: WebChatActivity = nextActivities.at(indexToInsert); + let upsertingPosition: number; + + if (prevSibling) { + const prevPosition = prevSibling.channelData['webchat:internal:position']; + + if (nextSibling) { + const nextSequenceId = nextSibling.channelData['webchat:internal:position']; + + // eslint-disable-next-line no-magic-numbers + upsertingPosition = (prevPosition + nextSequenceId) / 2; + } else { + upsertingPosition = prevPosition + 1; + } + } else if (nextSibling) { + const nextSequenceId = nextSibling.channelData['webchat:internal:position']; + + upsertingPosition = nextSequenceId - 1; + } else { + upsertingPosition = 1; + } + + upsertingActivity = updateIn( + upsertingActivity, + ['channelData', 'webchat:internal:position'], + () => upsertingPosition + ); + + nextActivities.splice(indexToInsert, 0, upsertingActivity); return nextActivities; } @@ -231,11 +279,36 @@ export default function createActivitiesReducer( payload: { activity } } = action; + activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => v4()); // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. // Please refer to #4362 for details. Remove on or after 2024-07-31. activity = updateIn(activity, ['channelData', 'state'], () => SENDING); activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => SENDING); + // Assume the message was sent immediately after the very last message. + // This helps to maintain the order of the outgoing message before the server respond. + activity = updateIn(activity, ['channelData', 'webchat:sequence-id'], () => { + const lastActivity = state.at(-1); + + if (!lastActivity) { + return 1; + } + + const lastSequenceId = lastActivity.channelData['webchat:sequence-id']; + + if (typeof lastSequenceId === 'number') { + return lastSequenceId + 1; + } + + const lastTimestampInNumber = +new ponyfill.Date(lastActivity.timestamp); + + if (!isNaN(lastTimestampInNumber)) { + return lastTimestampInNumber + 1; + } + + return +new ponyfill.Date(); + }); + state = upsertActivityWithSort(state, activity, ponyfill); } @@ -263,19 +336,40 @@ export default function createActivitiesReducer( case POST_ACTIVITY_FULFILLED: { - // We will replace the activity with the version from the server - const activity = updateIn( - updateIn( - patchActivity(action.payload.activity, state, ponyfill), - // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. - // Please refer to #4362 for details. Remove on or after 2024-07-31. - ['channelData', 'state'], - () => SENT - ), - ['channelData', 'webchat:send-status'], + const existingActivity = state.find(findByClientActivityID(action.meta.clientActivityID)); + + if (!existingActivity) { + throw new Error( + 'botframework-webchat-internal: On POST_ACTIVITY_FULFILLED, there is no activities with same client activity ID' + ); + } + + // We will replace the outgoing activity with the version from the server + let activity = patchActivity(action.payload.activity, ponyfill); + + activity = updateIn( + activity, + // `channelData.state` is being deprecated in favor of `channelData['webchat:send-status']`. + // Please refer to #4362 for details. Remove on or after 2024-07-31. + ['channelData', 'state'], () => SENT ); + activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => SENT); + + activity = updateIn( + activity, + ['channelData', 'webchat:internal:id'], + () => existingActivity.channelData['webchat:internal:id'] + ); + + // Keep existing position. + activity = updateIn( + activity, + ['channelData', 'webchat:internal:position'], + () => existingActivity.channelData['webchat:internal:position'] + ); + state = updateIn(state, [findByClientActivityID(action.meta.clientActivityID)], () => activity); } @@ -287,6 +381,11 @@ export default function createActivitiesReducer( payload: { activity } } = action; + // Clean internal properties if they were passed from chat adapter. + // These properties should not be passed from external systems. + activity = updateIn(activity, ['channelData', 'webchat:internal:id']); + activity = updateIn(activity, ['channelData', 'webchat:internal:position']); + // If the incoming activity is an echo back, we should keep the existing `channelData['webchat:send-status']` field. // // Otherwise, it will fail following scenario: @@ -323,18 +422,48 @@ export default function createActivitiesReducer( if (existingActivity) { const { - channelData: { 'webchat:send-status': sendStatus } + channelData: { 'webchat:internal:id': permanentId, 'webchat:send-status': sendStatus } } = existingActivity; + activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => permanentId); + if (sendStatus === SENDING || sendStatus === SEND_FAILED || sendStatus === SENT) { activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => sendStatus); } } else { + activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => v4()); + // If there are no existing activity, probably this activity is restored from chat history. // All outgoing activities restored from service means they arrived at the service successfully. // Thus, we are marking them as "sent". activity = updateIn(activity, ['channelData', 'webchat:send-status'], () => SENT); } + } else { + if (!activity.id) { + const newActivityId = v4(); + + console.warn( + 'botframework-webchat: Incoming activity must have "id" field set, assigning a random value as ID', + { + activity, + newActivityId + } + ); + + activity = updateIn(activity, ['id'], () => newActivityId); + } + + const existingActivity = state.find(({ id }) => id === activity.id); + + if (existingActivity) { + activity = updateIn( + activity, + ['channelData', 'webchat:internal:id'], + () => existingActivity.channelData['webchat:internal:id'] + ); + } else { + activity = updateIn(activity, ['channelData', 'webchat:internal:id'], () => v4()); + } } state = upsertActivityWithSort(state, activity, ponyfill); diff --git a/packages/core/src/types/WebChatActivity.ts b/packages/core/src/types/WebChatActivity.ts index aae6d52cd4..464c056bcd 100644 --- a/packages/core/src/types/WebChatActivity.ts +++ b/packages/core/src/types/WebChatActivity.ts @@ -23,8 +23,14 @@ type ChannelData({ channelData: { + 'webchat:internal:id': 'a-00001', + 'webchat:internal:position': 0, 'webchat:send-status': 'send failed', 'webchat:sequence-id': 0 }, diff --git a/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts b/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts index 952d9ff396..10ae4113fa 100644 --- a/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts +++ b/packages/core/test-d/direct-line-activity-from-user-sending.test-d.ts @@ -5,6 +5,8 @@ import { type WebChatActivity } from '../src/index'; // All activities that are sending, are activities that did not reach the server yet (a.k.a. activity-in-transit). expectAssignable({ channelData: { + 'webchat:internal:id': 'a-00001', + 'webchat:internal:position': 0, 'webchat:send-status': 'sending', 'webchat:sequence-id': 0 }, diff --git a/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts b/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts index fd60ffcf03..439d378453 100644 --- a/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts +++ b/packages/core/test-d/direct-line-activity-from-user-sent.test-d.ts @@ -5,6 +5,8 @@ import { type WebChatActivity } from '../src/index'; // All activities which are "sent", must be from server. expectAssignable({ channelData: { + 'webchat:internal:id': 'a-00001', + 'webchat:internal:position': 0, 'webchat:send-status': 'sent', 'webchat:sequence-id': 0 }, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 83f5c3b906..24fc63546f 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -6,6 +6,7 @@ const commonConfig = applyConfig(config => ({ ...config, entry: { 'botframework-webchat-core': './src/index.ts', + 'botframework-webchat-core.graph': './src/graph/index.ts', 'botframework-webchat-core.internal': './src/internal/index.ts' } })); diff --git a/packages/debug-theme/package.json b/packages/debug-theme/package.json index 435192b6a7..e76c3ae322 100644 --- a/packages/debug-theme/package.json +++ b/packages/debug-theme/package.json @@ -51,7 +51,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../api/package.json\" \"../component/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../component/package.json\"" }, "localDependencies": { "botframework-webchat-api": "production", diff --git a/packages/fluent-theme/package.json b/packages/fluent-theme/package.json index bfb4d6a0a5..974a5e855b 100644 --- a/packages/fluent-theme/package.json +++ b/packages/fluent-theme/package.json @@ -61,7 +61,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../styles/package.json\" \"../tsconfig/package.json\" \"../bundle/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../styles/package.json\" \"../bundle/package.json\"" }, "pinDependencies": { "@types/react": [ diff --git a/packages/redux-store/package.json b/packages/redux-store/package.json index 809b506f61..044624ccfb 100644 --- a/packages/redux-store/package.json +++ b/packages/redux-store/package.json @@ -45,7 +45,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-valibot/package.json\" \"../tsconfig/package.json\" \"../core/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-valibot/package.json\" \"../core/package.json\"" }, "pinDependencies": { "@types/react": [ diff --git a/packages/styles/package.json b/packages/styles/package.json index 93e9cae11a..fa1ab2038a 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -70,7 +70,7 @@ "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": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\" \"../tsconfig/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\"" }, "pinDependencies": { "typescript": [ diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js index 5d856d3366..5ff5cca2b0 100644 --- a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js +++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js @@ -134,9 +134,9 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill activity = updateIn(activity, ['timestamp'], timestamp => typeof timestamp === 'number' ? new Date(now + timestamp).toISOString() - : typeof timestamp === 'undefined' - ? getTimestamp() - : timestamp + : 'timestamp' in activity // If `activity.timestamp` is `undefined`, let it in. + ? timestamp + : getTimestamp() ); activity = updateIn(activity, ['type'], type => type || 'message'); }