diff --git a/bun.lock b/bun.lock index c397e9b..2286c5d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,16 @@ "": { "name": "bun-react-template", "dependencies": { - "@ng-org/alien-deepsignals": "0.1.2-alpha.11", - "@ng-org/orm": "0.1.2-alpha.18", - "@ng-org/shex-orm": "0.1.2-alpha.8", - "@ng-org/web": "0.1.2-alpha.13", + "@ng-org/alien-deepsignals": "0.1.2-alpha.13", + "@ng-org/orm": "0.1.2-alpha.21", + "@ng-org/refine-providers": "0.1.2-alpha.1", + "@ng-org/shex-orm": "0.1.2-alpha.9", + "@ng-org/web": "0.1.2-alpha.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@refinedev/core": "^5.0.12", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -187,13 +189,15 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "^2.0.7" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="], + "@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.13", "", { "dependencies": { "alien-signals": "^2.0.7" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-yOEh/J+9IrCo8WDXTdZLxRcO9IHAczrUA4pjSD4BaZzmWhnCFE/amy/0PsAdh/yePurBrtQj3tG/y+hReM15Ew=="], - "@ng-org/orm": ["@ng-org/orm@0.1.2-alpha.18", "", { "dependencies": { "@ng-org/alien-deepsignals": "0.1.2-alpha.11" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-LlCXFsGJWoKULn+EemsKntASEG3224AaT4mBC1DisTVtfGeGMaIowTimG31nQ7hTL3fssmCEWkZguP2/c6quUA=="], + "@ng-org/orm": ["@ng-org/orm@0.1.2-alpha.21", "", { "dependencies": { "@ng-org/alien-deepsignals": "0.1.2-alpha.13" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-+Dtt9jEa+277wonExxqC1BHqVwvWhXeXM51zcGg2w+Mc6C1F74qaXMGYZT3CcWCQISGEMOWV3n2movP3yQ8xJA=="], - "@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "^1.0.0-alpha.24", "commander": "^14.0.1", "dts-dom": "~3.6.0", "ejs": "^3.1.8", "fs-extra": "^10.1.0", "jsonld2graphobject": "^0.0.5", "loading-cli": "^1.1.0", "prettier": "^3.0.3" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="], + "@ng-org/refine-providers": ["@ng-org/refine-providers@0.1.2-alpha.1", "", { "peerDependencies": { "@ng-org/alien-deepsignals": "0.1.2-alpha.13", "@ng-org/orm": "0.1.2-alpha.21", "@ng-org/shex-orm": "0.1.2-alpha.9", "@ng-org/web": "0.1.2-alpha.14", "@refinedev/core": "^5.0.12" } }, "sha512-MSaVTyCWiqbBR7jn4ESgCk8zYrxWW1EoZCquYAxIud1XBMtkZvl0X/rjIdCPG2GxAVHURC/ut3moavd5Eb+gsA=="], - "@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "^0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="], + "@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.9", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "^1.0.0-alpha.24", "commander": "^14.0.1", "dts-dom": "~3.6.0", "ejs": "^3.1.8", "fs-extra": "^10.1.0", "jsonld2graphobject": "^0.0.5", "loading-cli": "^1.1.0", "prettier": "^3.0.3" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-M69oIHrcjl24xKXIaiZjZ64rJvispAPIqn73asLPR73GWpEMvbiot/EN0vDkcfrbRy3cMEql36A9e1Nwv2csOA=="], + + "@ng-org/web": ["@ng-org/web@0.1.2-alpha.14", "", { "dependencies": { "async-proxy": "^0.4.1" } }, "sha512-FPQ7kyngMhAXK/WJtZ6pkoEJwGcau6GaBH2nhjjYorMq7OG//I+zwghat90FsIeMrfYsps0+5Y0UrRqfxNdUXA=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w=="], @@ -279,6 +283,12 @@ "@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="], + "@refinedev/core": ["@refinedev/core@5.0.12", "", { "dependencies": { "@refinedev/devtools-internal": "2.0.2", "@tanstack/react-query": "^5.81.5", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "papaparse": "^5.3.0", "pluralize": "^8.0.0", "qs": "^6.10.1", "tslib": "^2.6.2", "warn-once": "^0.1.0" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg=="], + + "@refinedev/devtools-internal": ["@refinedev/devtools-internal@2.0.2", "", { "dependencies": { "@refinedev/devtools-shared": "2.0.2", "@tanstack/react-query": "^5.81.5", "error-stack-parser": "^2.1.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg=="], + + "@refinedev/devtools-shared": ["@refinedev/devtools-shared@2.0.2", "", { "dependencies": { "@tanstack/react-query": "^5.81.5", "error-stack-parser": "^2.1.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA=="], + "@shexjs/parser": ["@shexjs/parser@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/util": "^1.0.0-alpha.28", "@ts-jison/parser": "^0.4.1-alpha.1" } }, "sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw=="], "@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "^2.1.6", "rdf-data-factory": "^1.1.2", "relativize-url": "^0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="], @@ -345,6 +355,10 @@ "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.103.2", "", {}, "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.103.2", "", { "dependencies": { "@tanstack/query-core": "5.103.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw=="], + "@teppeis/multimaps": ["@teppeis/multimaps@3.0.0", "", {}, "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -851,6 +865,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.mergewith": ["lodash.mergewith@4.6.2", "", {}, "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="], @@ -937,6 +953,8 @@ "pad-right": ["pad-right@0.2.2", "", { "dependencies": { "repeat-string": "^1.5.2" } }, "sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g=="], + "papaparse": ["papaparse@5.7.0", "", {}, "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA=="], + "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -969,6 +987,8 @@ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], @@ -1199,6 +1219,8 @@ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], + "warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], diff --git a/package.json b/package.json index d60c7df..42a81e9 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,16 @@ "build-storybook": "storybook build" }, "dependencies": { - "@ng-org/alien-deepsignals": "0.1.2-alpha.11", - "@ng-org/orm": "0.1.2-alpha.18", - "@ng-org/shex-orm": "0.1.2-alpha.8", - "@ng-org/web": "0.1.2-alpha.13", + "@ng-org/alien-deepsignals": "0.1.2-alpha.13", + "@ng-org/orm": "0.1.2-alpha.21", + "@ng-org/refine-providers": "0.1.2-alpha.1", + "@ng-org/shex-orm": "0.1.2-alpha.9", + "@ng-org/web": "0.1.2-alpha.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@refinedev/core": "^5.0.12", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/App.tsx b/src/app/App.tsx index bd1ec53..95f8e7b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router'; import { ThemeProvider } from '../shared/context/ThemeContext'; import { NextGraphProvider } from '../shared/context/NextGraphContext'; import { AccountProvider } from '../shared/context/AccountContext'; +import { RefineProvider } from '../shared/refine/RefineProvider'; import { FestipodDataProvider } from '../shared/context/FestipodDataContext'; import { AuthGate } from './AuthGate'; import { ToastContainer } from '../shared/components/sketchy'; @@ -58,16 +59,20 @@ export function App() { - - -
- - - - -
-
-
+ {/* Refine wraps the data context: FestipodDataContext reads and writes + events through Refine's hooks, so it must sit inside . */} + + + +
+ + + + +
+
+
+
diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index f095772..36d280c 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -14,11 +14,13 @@ import { seedMeetingPoints, seedFriendships, } from '../data/seedData'; +import { useList, useCreate, useUpdate } from '@refinedev/core'; import { useNextGraph } from './NextGraphContext'; import { useAccount, normalizeUsername } from './AccountContext'; import { applyIsolation } from '../utils/isolation'; -import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry'; +import { ensureAccount, resolveReadGraphs, resolveWriteGraph } from '../utils/storeRegistry'; import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults'; +import { RESOURCE_EVENTS } from '../refine/shapeTypes'; // Multi-document mode (storeRegistry): one document per (account × scope), // mirroring the target per-user stores. Default OFF — the validated mono-store @@ -28,8 +30,9 @@ import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefa // custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws // "process is not defined" in the browser harness. Guard it. const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1'; +// `FpEventShapeType` is NOT imported here any more: events go through Refine, and +// the resource → shape binding lives in refine/shapeTypes.ts. import { - FpEventShapeType, FpUserProfileShapeType, FpParticipationShapeType, } from '../shapes/orm/festipodShapes.shapeTypes'; @@ -107,6 +110,23 @@ const mapEvent = (e: FpEvent): FpEventData => ({ hostInitials: e.hostInitials, }); +/** + * Every persisted field of an event, in the form the Refine data provider takes. + * `@type` is carried explicitly — see `createEventDoc` for why it must be. + */ +const eventFields = (e: Omit) => ({ + "@type": "http://festipod.org/Event", + title: e.title, + description: e.description, + date: e.date, + location: e.location, + distance: e.distance, + participantCount: e.participantCount, + coverImage: e.coverImage, + hostName: e.hostName, + hostInitials: e.hostInitials, +}); + const mapUser = (u: FpUserProfile): FpUserData => ({ id: u["@id"], name: u.name, @@ -251,7 +271,7 @@ function useNgData(): FestipodDataContextValue { // Multi-document state (storeRegistry): read fan-out (all accounts' docs per // scope) and the current account's write docs. Populated by the effect below. - const [readGraphs, setReadGraphs] = useState<{ public: string[]; protected: string[] }>({ public: [], protected: [] }); + const [readGraphs, setReadGraphs] = useState<{ protected: string[] }>({ protected: [] }); const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({}); useEffect(() => { @@ -260,15 +280,16 @@ function useNgData(): FestipodDataContextValue { (async () => { try { await ensureAccount(username); // create this account's index docs on first sight - // Public = per-entity documents (events/PdR) listed by the public index. // Protected = grouped in each account's protected index document. - const [pub, prot, wProt] = await Promise.all([ - listEntityDocs('public'), + // NO public fan-out any more: events are a Refine resource, and the data + // provider addresses them by shape across every graph it can see — it + // never takes a graph list, so the public index has no reader left. + const [prot, wProt] = await Promise.all([ resolveReadGraphs('protected'), resolveWriteGraph(username, 'protected'), ]); if (cancelled) return; - setReadGraphs({ public: pub, protected: prot }); + setReadGraphs({ protected: prot }); setWriteGraphs({ protected: wProt }); } catch (err) { console.error('[FestipodData] storeRegistry init failed:', err); @@ -277,28 +298,92 @@ function useNgData(): FestipodDataContextValue { return () => { cancelled = true; }; }, [privateNuri, username]); - // Scope per entity: events live in the PUBLIC docs, profiles + participations - // in the PROTECTED docs. Mono-store mode collapses all to the private store. - const publicScope: ShapeScope = MULTISTORE - ? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined) - : privateNuri; + // Scope for profiles + participations, which still read through the ORM. + // Mono-store mode collapses them to the private store. const protectedScope: ShapeScope = MULTISTORE ? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined) : privateNuri; + // --- Events: read through REFINE ----------------------------------------- + // `useList` goes to `@ng-org/refine-providers`, which resolves the resource + // name to its SHEX shape (see refine/shapeTypes.ts) and reads every graph the + // verifier holds. Pagination is off: the screens show the whole list and the + // provider does not paginate at the source anyway. + const { result: eventsResult, query: eventsQuery } = useList({ + resource: RESOURCE_EVENTS, + pagination: { mode: 'off' }, + }); + // The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its + // `update` addresses) and `@id` (the SUBJECT IRI, which is what participations + // reference). `FpEventData.id` stays the subject IRI so `isParticipating` and + // `getEventParticipants` keep matching; the document NURI is looked up below. + const eventRows = eventsResult?.data ?? []; + // "No events" and "not read yet" are different answers, and the auto-seed below + // must not confuse them — an empty list while the query is still in flight is + // not an empty wallet. + const eventsRead = eventsQuery.isSuccess; + // useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults) - const emptyEvents: FpEventData[] = []; const emptyUsers: FpUserData[] = []; const emptyParticipations: FpParticipationData[] = []; - const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true); const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true); const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true); - const events = eventsShape.items; + const events = eventRows.map(mapEvent); const users = usersShape.items; const participations = participationsShape.items; + // --- Events: write through REFINE ---------------------------------------- + const { mutateAsync: createEventRow } = useCreate(); + const { mutateAsync: updateEventRow } = useUpdate(); + + /** + * Create ONE event as its own document. Returns the stored row, whose `@id` + * is the subject IRI the participations reference. + * + * `@type` IS NOT OPTIONAL. The data provider writes a hardcoded ActivityStreams + * `Note` type (the type of the example app it was written against) and spreads + * the caller's `values` AFTER it — so passing `@type` is what makes the record + * a Festipod Event rather than a Note. Drop it and the shape stops matching on + * read, and the event disappears. + */ + const createEventDoc = useCallback( + async (event: Omit): Promise => { + const created = await createEventRow({ + resource: RESOURCE_EVENTS, + values: eventFields(event), + }); + return created?.data; + }, + [createEventRow], + ); + + /** + * Overwrite an event, addressed by its app id (the subject IRI). + * + * The provider's `update` REPLACES the record — it deletes the old object and + * adds a new one built from `values` — so every persisted field has to be sent. + * A partial write here would silently drop whatever it left out, which is why + * the current row is read back and merged first. + */ + const writeEvent = useCallback( + async (id: string, updates: Partial): Promise => { + const row = eventRows.find(r => r['@id'] === id); + if (!row) { + console.warn('[FestipodData] updateEvent: no event known under', id); + return; + } + await updateEventRow({ + resource: RESOURCE_EVENTS, + // Refine addresses the record by its DOCUMENT NURI, not its subject IRI. + id: row['@graph'], + values: eventFields({ ...mapEvent(row), ...updates }), + }); + }, + [eventRows, updateEventRow], + ); + // Not in SHEX shapes yet const [meetingPoints, setMeetingPoints] = useState([]); const [friendships, setFriendships] = useState([]); @@ -315,9 +400,13 @@ function useNgData(): FestipodDataContextValue { }, [events.length, selectedEventId]); // Dev auto-seed: if the wallet is still empty 3s after the session is ready, - // bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0 + // bootstrap with seed data. `bootstrapWallet()` self-checks (existing data // → skip), so this is safe even if shapes finish hydrating after the timer. // Gated on NODE_ENV so production users see their own (possibly empty) wallet. + // + // `eventsRead` is the guard that the ORM sets used to give for free: an empty + // Refine list means nothing until its query has actually succeeded, and seeding + // on an in-flight read would write the fixtures a second time. const hasTriedAutoSeed = useRef(false); useEffect(() => { if (process.env.NODE_ENV === 'production') return; @@ -325,20 +414,25 @@ function useNgData(): FestipodDataContextValue { if (hasTriedAutoSeed.current) return; if (!privateNuri) return; const t = setTimeout(() => { + if (!eventsRead) { + console.log('[FestipodData] Dev auto-seed: events not read yet — skip'); + return; + } hasTriedAutoSeed.current = true; - if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) { + if (eventRows.length === 0 && usersShape.ngSet.size === 0) { console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…'); - bootstrapWallet( - eventsShape.ngSet as any, - usersShape.ngSet as any, - participationsShape.ngSet as any, - ).catch(err => console.error('[FestipodData] Auto-seed failed:', err)); + bootstrapWallet({ + eventCount: eventRows.length, + seedEvent: createEventDoc, + ngUsers: usersShape.ngSet as any, + ngParticipations: participationsShape.ngSet as any, + }).catch(err => console.error('[FestipodData] Auto-seed failed:', err)); } else { console.log('[FestipodData] Dev auto-seed: wallet already has data — skip'); } }, 3000); return () => clearTimeout(t); - }, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]); + }, [privateNuri, eventsRead, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]); // --- Derived --- // Resolve current user from the chosen account username (the perceived login); @@ -372,52 +466,29 @@ function useNgData(): FestipodDataContextValue { const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || ''; const createEvent = useCallback(async (event: Omit): Promise => { - console.log('[FestipodData] createEvent (NG):', event.title); - // Per-entity: in multistore each event is its OWN document. Mono-store: the - // private store. (Multistore create reactivity is best-effort — the new doc - // is appended to the read fan-out so it shows after re-subscribe.) - const eventGraph = MULTISTORE - ? await createEntityDoc(username || '', 'public') - : (privateNuri || ''); - if (MULTISTORE && eventGraph) { - setReadGraphs(prev => - prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] }, - ); - } - eventsShape.ngSet.add({ - "@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "", - title: event.title, description: event.description, date: event.date, - location: event.location, distance: event.distance, - participantCount: event.participantCount || 1, - coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials, - } as FpEvent); - const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title); - if (addedEvent && currentUserId) { + console.log('[FestipodData] createEvent (Refine):', event.title); + // The data provider creates the document itself, one per record — so there is + // no graph to resolve here any more, and nothing to append to a read fan-out. + const stored = await createEventDoc({ ...event, participantCount: event.participantCount || 1 }); + const eventIri = stored?.["@id"]; + if (eventIri && currentUserId) { participationsShape.ngSet.add({ "@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "", - event: addedEvent["@id"], user: currentUserId, isConfirmed: true, + event: eventIri, user: currentUserId, isConfirmed: true, } as FpParticipation); - setSelectedEventId(addedEvent["@id"]); + setSelectedEventId(eventIri); } - return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; - }, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]); + return { ...event, id: eventIri || `ng-pending-${Date.now()}` }; + }, [protectedGraph, createEventDoc, participationsShape.ngSet, currentUserId]); const updateEvent = useCallback((id: string, updates: Partial) => { - console.log('[FestipodData] updateEvent (NG):', id, updates); - const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === id); - if (ngEvent) { - if (updates.title !== undefined) ngEvent.title = updates.title; - if (updates.description !== undefined) ngEvent.description = updates.description; - if (updates.date !== undefined) ngEvent.date = updates.date; - if (updates.location !== undefined) ngEvent.location = updates.location; - if (updates.distance !== undefined) ngEvent.distance = updates.distance; - if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount; - } - }, [eventsShape.ngSet]); + console.log('[FestipodData] updateEvent (Refine):', id, updates); + void writeEvent(id, updates); + }, [writeEvent]); const joinEvent = useCallback((eventId: string, userId?: string) => { const uid = userId || currentUserId; - console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid); + console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid); const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid); if (existing) { console.log('[FestipodData] Already participating, skipping'); @@ -427,25 +498,27 @@ function useNgData(): FestipodDataContextValue { "@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "", event: eventId, user: uid, isConfirmed: true, } as FpParticipation); - const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); - if (ngEvent) { - ngEvent.participantCount = ngEvent.participantCount + 1; + // The count lives on the event, which is now a Refine record: bumping it is a + // full record write, not the in-place ORM mutation it used to be. + const current = eventRows.find(r => r["@id"] === eventId); + if (current) { + void writeEvent(eventId, { participantCount: current.participantCount + 1 }); } - }, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]); + }, [protectedGraph, participationsShape.ngSet, eventRows, writeEvent, currentUserId]); const leaveEvent = useCallback((eventId: string, userId?: string) => { const uid = userId || currentUserId; - console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid); + console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid); const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid); if (ngPart) { console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]); participationsShape.ngSet.delete(ngPart); - const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); - if (ngEvent) { - ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1); + const current = eventRows.find(r => r["@id"] === eventId); + if (current) { + void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) }); } } - }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]); + }, [participationsShape.ngSet, eventRows, writeEvent, currentUserId]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -476,12 +549,13 @@ function useNgData(): FestipodDataContextValue { const loadTestData = useCallback(async (): Promise => { console.log('[FestipodData] loadTestData (NG)'); - return bootstrapWallet( - eventsShape.ngSet as any, - usersShape.ngSet as any, - participationsShape.ngSet as any, - ); - }, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]); + return bootstrapWallet({ + eventCount: eventRows.length, + seedEvent: createEventDoc, + ngUsers: usersShape.ngSet as any, + ngParticipations: participationsShape.ngSet as any, + }); + }, [eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]); return { currentUserId, currentUser, diff --git a/src/shared/refine/RefineProvider.tsx b/src/shared/refine/RefineProvider.tsx new file mode 100644 index 0000000..a600155 --- /dev/null +++ b/src/shared/refine/RefineProvider.tsx @@ -0,0 +1,49 @@ +/** + * RefineProvider — mounts Refine over the NextGraph providers. + * + * This is the app's new data entry point for the Events vertical: screens keep + * calling `useFestipodData()`, and `FestipodDataContext` now reads and writes + * events through Refine's hooks, which land on `@ng-org/refine-providers`. + * + * NO ROUTER PROVIDER, deliberately. Festipod has its own path-based router + * (`app/router.tsx`); handing Refine a second one would give the app two actors + * navigating the same history. Refine works without it — only its navigation + * helpers (which this app does not call) go dormant. + * + * The providers are built ONCE at module scope, as the package's README does. + * Rebuilding the data provider on a render would hand Refine a new provider + * identity and re-run every query behind it. + */ + +import type { ReactNode } from 'react'; +import { Refine } from '@refinedev/core'; +import { dataProvider, liveProvider } from '@ng-org/refine-providers'; +import shapeTypes, { RESOURCE_EVENTS } from './shapeTypes'; +import { createNgAuthProvider } from './ngAuthProvider'; + +const ngAuthProvider = createNgAuthProvider(); +const ngDataProvider = dataProvider({ shapeTypes, authProvider: ngAuthProvider }); +const ngLiveProvider = liveProvider({ shapeTypes }); + +export function RefineProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/shared/refine/ngAuthProvider.ts b/src/shared/refine/ngAuthProvider.ts new file mode 100644 index 0000000..2df6a07 --- /dev/null +++ b/src/shared/refine/ngAuthProvider.ts @@ -0,0 +1,72 @@ +/** + * ngAuthProvider — the `AuthProvider` that `@ng-org/refine-providers` asks to be + * handed, backed by Festipod's own NextGraph session. + * + * WHY NOT THE PACKAGE'S OWN `authProvider()`. Its factory calls `init(…)` from + * `@ng-org/web` the moment it runs, and then `initNg(ng, session)` — which is + * exactly, line for line, what `utils/ngSession.init()` already does. Using both + * would register TWO init callbacks against one WASM session: outside the broker + * iframe `init` REDIRECTS the page, so the second registration is a second + * redirect, and the two callbacks race to be the one that owns `session`. + * + * `DataProviderConfig` declares `authProvider` as an INJECTION POINT — the data + * provider only ever calls `getSession()` on it, to read a `session_id` for + * `doc_create`. So supplying Festipod's session here is using the published + * surface as it is meant to be used, not working around it: the session stays + * single, and `ngSession` stays the only module that calls `init`. + * + * The remaining `AuthProvider` members are Refine's auth contract. They are wired + * to what Festipod actually has — the technical NextGraph login/logout — rather + * than stubbed: `check` reports whether the wallet session is open, and nothing + * here invents an identity the app does not hold. + */ + +import type { AuthProvider } from '@ng-org/refine-providers'; +import type { Session } from '@ng-org/web'; +import { session, login as ngLogin, logoutNg } from '../utils/ngSession'; + +export function createNgAuthProvider(): AuthProvider { + return { + /** + * The real technical login: opening the wallet page. `ngSession.login()` + * navigates away, so the resolved value is only reached when the broker + * round-trip did not happen (already logged in). + */ + login: async () => { + await ngLogin(); + return { success: true }; + }, + + logout: async () => { + await logoutNg(); + return { success: true }; + }, + + /** + * Authenticated = the NextGraph session is open. `AuthGate` is what actually + * gates the screens (it reads `NextGraphContext`), so no `redirectTo` is + * returned here: Refine has no router in this app and must not try to route. + */ + check: async () => ({ authenticated: session !== undefined }), + + onError: async (error) => ({ error }), + + /** + * The NextGraph session carries the wallet user id under `user` when one is + * open. Festipod's PERCEIVED login (the chosen username) lives in + * `AccountContext`, which is React state and not reachable from here — so + * this reports the technical identity only, and `undefined` when there is none. + */ + getIdentity: async () => { + const user = session?.user; + return typeof user === 'string' && user ? { id: user } : undefined; + }, + + /** + * The handle the data provider actually consumes. Festipod's + * `NextGraphSession` is the same object `@ng-org/web` hands its `init` + * callback; the cast only reconciles the two local declarations of it. + */ + getSession: () => session as Session | undefined, + }; +} diff --git a/src/shared/refine/shapeTypes.ts b/src/shared/refine/shapeTypes.ts new file mode 100644 index 0000000..1263928 --- /dev/null +++ b/src/shared/refine/shapeTypes.ts @@ -0,0 +1,25 @@ +/** + * The Refine RESOURCE MAP — the one place that says which Refine resource name + * stands for which SHEX shape. + * + * `@ng-org/refine-providers` identifies data by shape type, not by endpoint: its + * data provider looks `shapeTypes[resource]` up on every call, so a resource name + * that is absent here silently resolves to `undefined` and the read comes back + * empty. Every `useList` / `useOne` / `useCreate` / `useUpdate` call site must + * therefore name a resource declared below. + * + * Only `events` is wired: the Events vertical is what goes through Refine — + * profiles and participations still read through `useShapeWithDefaults`. + */ + +import type { ShapeTypes } from '@ng-org/refine-providers'; +import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes'; + +/** Refine resource name for Festipod events. Import it — never retype the string. */ +export const RESOURCE_EVENTS = 'events'; + +const shapeTypes: ShapeTypes = { + [RESOURCE_EVENTS]: FpEventShapeType, +}; + +export default shapeTypes; diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index c9de98a..12ab0a1 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -3,10 +3,20 @@ * * Called once after NG connection + shapes ready. If the wallet already * has events/users, it's a returning user — skip seeding. + * + * EVENTS ARE SEEDED THROUGH REFINE, not through an ORM set like users and + * participations. They have to be: the Refine data provider derives a record's + * `id` from its `@graph`, and it creates a fresh document on every `create`. + * Seeding events the old way — all of them into one shared graph — would give + * every seeded event the SAME Refine id, so the list would collide on itself and + * an update would address the whole document instead of one event. The seeder is + * injected rather than imported because `useCreate` is a React hook and this + * module runs outside the component tree. */ import type { DeepSignalSet } from '@ng-org/alien-deepsignals'; import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings'; +import type { FpEventData } from '../data/types'; import { ensureGraphNuri } from './ngGraph'; import { seedEvents, @@ -20,6 +30,21 @@ export interface BootstrapResult { eventIdMap: Map; } +/** Writes ONE event as its own document and returns the stored row (or nothing). */ +export type EventSeeder = (event: Omit) => Promise; + +export interface BootstrapDeps { + /** + * How many events the Refine-backed read already sees. This is the + * "returning user" check that `ngEvents.size` used to answer — the caller owns + * it now, because the events no longer come from a set this module can count. + */ + eventCount: number; + seedEvent: EventSeeder; + ngUsers: DeepSignalSet; + ngParticipations: DeepSignalSet; +} + /** * Flush ORM microtask batch and give the broker time to process. * @@ -33,22 +58,21 @@ async function flushAndWait(ms = 100): Promise { } export async function bootstrapWallet( - ngEvents: DeepSignalSet, - ngUsers: DeepSignalSet, - ngParticipations: DeepSignalSet, + { eventCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps, ): Promise { // Already has data → returning user, nothing to seed - if (ngEvents.size > 0 || ngUsers.size > 0) { - console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size, + if (eventCount > 0 || ngUsers.size > 0) { + console.log('[Bootstrap] Wallet already has data — events:', eventCount, 'users:', ngUsers.size, 'participations:', ngParticipations.size); return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() }; } console.log('[Bootstrap] First time for this wallet — seeding default data...'); - // Create (or get) a document in the private store for our data. - // The ORM requires a real document NURI as @graph, not the store ID. - const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations); + // Create (or get) a document in the private store for users + participations. + // The ORM requires a real document NURI as @graph, not the store ID. Events + // are NOT placed here: each one gets its own document, created by `seedEvent`. + const graph = await ensureGraphNuri(ngUsers, ngParticipations); console.log('[Bootstrap] Using graph NURI:', graph); // Seed users — one at a time with ORM flush between each @@ -70,26 +94,22 @@ export async function bootstrapWallet( } console.log('[Bootstrap] Seeded', userIdMap.size, 'users'); - // Seed events — one at a time with ORM flush between each + // Seed events — one at a time, each into its OWN document via Refine. + // The map is keyed on the seed id and holds the SUBJECT IRI, because that is + // what the participations below reference (`fp:event`). const eventIdMap = new Map(); for (const e of seedEvents) { - ngEvents.add({ - "@graph": graph, - "@type": "http://festipod.org/Event", - "@id": "", - title: e.title, - description: e.description, - date: e.date, - location: e.location, - distance: e.distance, - participantCount: e.participantCount, - coverImage: e.coverImage, - hostName: e.hostName, - hostInitials: e.hostInitials, - } as FpEvent); + const stored = await seedEvent(e); await flushAndWait(); - const added = [...ngEvents].find(ne => ne.title === e.title); - if (added) eventIdMap.set(e.id, added["@id"]); + const iri = stored?.["@id"]; + if (iri) { + eventIdMap.set(e.id, iri); + } else { + // Loud, not silent: without a subject IRI the participations that point at + // this event would be written against the SEED id, which nothing resolves. + console.warn('[Bootstrap] Event seeded without a subject IRI — participations ' + + 'referencing it will not resolve:', e.title); + } } console.log('[Bootstrap] Seeded', eventIdMap.size, 'events'); diff --git a/tsconfig.json b/tsconfig.json index 632a36f..f34a97c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,10 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, + // `baseUrl` is deprecated in TS 6 and the deprecation is reported as a CONFIG + // error — which makes tsc exit(2) WITHOUT typechecking a single file. The + // typecheck gate was silently inert; this silences the deprecation so it runs. + "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { "@/*": ["./src/*"]