From 337f2011f32846dac178232e9f433e3d7a3a3c20 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 13 Aug 2025 11:30:38 +0100 Subject: [PATCH 01/18] wip devtools - squashed --- .gitignore | 1 + examples/react/todo/package.json | 9 +- examples/react/todo/src/lib/collections.ts | 16 +- examples/react/todo/src/routes/__root.tsx | 4 + packages/db-devtools/README.md | 173 ++++ packages/db-devtools/eslint.config.js | 33 + packages/db-devtools/package.json | 93 ++ .../src/BaseTanStackDbDevtoolsPanel.tsx | 212 +++++ .../src/FloatingTanStackDbDevtools.tsx | 274 ++++++ .../db-devtools/src/TanstackDbDevtools.tsx | 126 +++ .../src/components/CollectionDetailsPanel.tsx | 196 +++++ .../src/components/CollectionItem.tsx | 32 + .../src/components/CollectionStats.tsx | 52 ++ .../src/components/CollectionsPanel.tsx | 146 ++++ .../src/components/DetailsPanel.tsx | 168 ++++ .../db-devtools/src/components/Explorer.tsx | 361 ++++++++ packages/db-devtools/src/components/Logo.tsx | 18 + .../src/components/TabNavigation.tsx | 46 + .../src/components/TransactionItem.tsx | 31 + .../src/components/TransactionStats.tsx | 33 + .../src/components/TransactionsPanel.tsx | 58 ++ packages/db-devtools/src/components/index.ts | 11 + packages/db-devtools/src/constants.ts | 30 + .../src/contexts/NavigationContext.tsx | 108 +++ packages/db-devtools/src/contexts/index.tsx | 18 + packages/db-devtools/src/devtools.ts | 153 ++++ packages/db-devtools/src/index.ts | 15 + packages/db-devtools/src/logo.tsx | 817 ++++++++++++++++++ packages/db-devtools/src/registry.ts | 379 ++++++++ packages/db-devtools/src/tokens.ts | 264 ++++++ packages/db-devtools/src/types.ts | 134 +++ packages/db-devtools/src/useLocalStorage.ts | 41 + packages/db-devtools/src/useStyles.tsx | 569 ++++++++++++ packages/db-devtools/src/utils.tsx | 102 +++ packages/db-devtools/src/utils/formatTime.ts | 20 + packages/db-devtools/src/utils/index.ts | 1 + packages/db-devtools/tsconfig.json | 16 + packages/db-devtools/tsconfig.prod.json | 11 + packages/db-devtools/tsup.config.ts | 20 + packages/db-devtools/vite.config.ts | 25 + packages/db/src/collection.ts | 66 ++ packages/db/src/local-only.ts | 1 + packages/db/src/local-storage.ts | 1 + .../db/src/query/live-query-collection.ts | 2 + packages/db/src/types.ts | 5 + packages/db/tsconfig.json | 2 +- .../electric-db-collection/src/electric.ts | 1 + packages/query-db-collection/src/query.ts | 2 + packages/react-db-devtools/package.json | 97 +++ .../react-db-devtools/src/ReactDbDevtools.tsx | 103 +++ packages/react-db-devtools/src/index.ts | 51 ++ packages/react-db-devtools/tsconfig.json | 14 + packages/react-db-devtools/tsconfig.prod.json | 12 + packages/react-db-devtools/tsup.config.ts | 32 + .../trailbase-db-collection/src/trailbase.ts | 1 + pnpm-lock.yaml | 552 +++++++++++- 56 files changed, 5743 insertions(+), 15 deletions(-) create mode 100644 packages/db-devtools/README.md create mode 100644 packages/db-devtools/eslint.config.js create mode 100644 packages/db-devtools/package.json create mode 100644 packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx create mode 100644 packages/db-devtools/src/FloatingTanStackDbDevtools.tsx create mode 100644 packages/db-devtools/src/TanstackDbDevtools.tsx create mode 100644 packages/db-devtools/src/components/CollectionDetailsPanel.tsx create mode 100644 packages/db-devtools/src/components/CollectionItem.tsx create mode 100644 packages/db-devtools/src/components/CollectionStats.tsx create mode 100644 packages/db-devtools/src/components/CollectionsPanel.tsx create mode 100644 packages/db-devtools/src/components/DetailsPanel.tsx create mode 100644 packages/db-devtools/src/components/Explorer.tsx create mode 100644 packages/db-devtools/src/components/Logo.tsx create mode 100644 packages/db-devtools/src/components/TabNavigation.tsx create mode 100644 packages/db-devtools/src/components/TransactionItem.tsx create mode 100644 packages/db-devtools/src/components/TransactionStats.tsx create mode 100644 packages/db-devtools/src/components/TransactionsPanel.tsx create mode 100644 packages/db-devtools/src/components/index.ts create mode 100644 packages/db-devtools/src/constants.ts create mode 100644 packages/db-devtools/src/contexts/NavigationContext.tsx create mode 100644 packages/db-devtools/src/contexts/index.tsx create mode 100644 packages/db-devtools/src/devtools.ts create mode 100644 packages/db-devtools/src/index.ts create mode 100644 packages/db-devtools/src/logo.tsx create mode 100644 packages/db-devtools/src/registry.ts create mode 100644 packages/db-devtools/src/tokens.ts create mode 100644 packages/db-devtools/src/types.ts create mode 100644 packages/db-devtools/src/useLocalStorage.ts create mode 100644 packages/db-devtools/src/useStyles.tsx create mode 100644 packages/db-devtools/src/utils.tsx create mode 100644 packages/db-devtools/src/utils/formatTime.ts create mode 100644 packages/db-devtools/src/utils/index.ts create mode 100644 packages/db-devtools/tsconfig.json create mode 100644 packages/db-devtools/tsconfig.prod.json create mode 100644 packages/db-devtools/tsup.config.ts create mode 100644 packages/db-devtools/vite.config.ts create mode 100644 packages/react-db-devtools/package.json create mode 100644 packages/react-db-devtools/src/ReactDbDevtools.tsx create mode 100644 packages/react-db-devtools/src/index.ts create mode 100644 packages/react-db-devtools/tsconfig.json create mode 100644 packages/react-db-devtools/tsconfig.prod.json create mode 100644 packages/react-db-devtools/tsup.config.ts diff --git a/.gitignore b/.gitignore index 528e53f21..9424004b1 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,7 @@ out # Nuxt.js build / generate output .nuxt dist +build # Gatsby files .cache/ diff --git a/examples/react/todo/package.json b/examples/react/todo/package.json index b8c287f10..bac0a0bde 100644 --- a/examples/react/todo/package.json +++ b/examples/react/todo/package.json @@ -3,24 +3,27 @@ "private": true, "version": "0.1.1", "dependencies": { + "@tanstack/db-devtools": "workspace:*", "@tanstack/electric-db-collection": "^0.1.0", "@tanstack/query-core": "^5.75.7", "@tanstack/query-db-collection": "^0.2.0", "@tanstack/react-db": "^0.1.0", + "@tanstack/react-db-devtools": "workspace:*", "@tanstack/react-router": "^1.125.6", + "@tanstack/react-router-devtools": "^1.130.2", "@tanstack/react-start": "^1.126.1", - "@tanstack/trailbase-db-collection": "^0.1.0", + "@tanstack/trailbase-db-collection": "^0.1.2", "cors": "^2.8.5", "drizzle-orm": "^0.40.1", "drizzle-zod": "^0.8.3", - "zod": "^4.0.17", "express": "^4.19.2", "postgres": "^3.4.7", "react": "^19.1.0", "react-dom": "^19.1.0", "tailwindcss": "^4.1.11", "trailbase": "^0.7.1", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "zod": "^4.0.17" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/examples/react/todo/src/lib/collections.ts b/examples/react/todo/src/lib/collections.ts index 5e41bac92..7ba1cd161 100644 --- a/examples/react/todo/src/lib/collections.ts +++ b/examples/react/todo/src/lib/collections.ts @@ -1,4 +1,5 @@ import { createCollection } from "@tanstack/react-db" +import { initializeDbDevtools } from "@tanstack/react-db-devtools" import { electricCollectionOptions } from "@tanstack/electric-db-collection" import { queryCollectionOptions } from "@tanstack/query-db-collection" import { trailBaseCollectionOptions } from "@tanstack/trailbase-db-collection" @@ -11,13 +12,16 @@ import type { SelectConfig, SelectTodo } from "../db/validation" // Create a query client for query collections const queryClient = new QueryClient() +// Initialize DB devtools early (idempotent - safe to call multiple times) +initializeDbDevtools() + // Create a TrailBase client. const trailBaseClient = initClient(`http://localhost:4000`) // Electric Todo Collection export const electricTodoCollection = createCollection( electricCollectionOptions({ - id: `todos`, + id: `electric-todos`, shapeOptions: { url: `http://localhost:3003/v1/shape`, params: { @@ -71,7 +75,7 @@ export const electricTodoCollection = createCollection( // Query Todo Collection export const queryTodoCollection = createCollection( queryCollectionOptions({ - id: `todos`, + id: `query-todos`, queryKey: [`todos`], refetchInterval: 3000, queryFn: async () => { @@ -130,7 +134,7 @@ type Todo = { // TrailBase Todo Collection export const trailBaseTodoCollection = createCollection( trailBaseCollectionOptions({ - id: `todos`, + id: `trailbase-todos`, getKey: (item) => item.id, schema: selectTodoSchema, recordApi: trailBaseClient.records(`todos`), @@ -149,7 +153,7 @@ export const trailBaseTodoCollection = createCollection( // Electric Config Collection export const electricConfigCollection = createCollection( electricCollectionOptions({ - id: `config`, + id: `electric-config`, shapeOptions: { url: `http://localhost:3003/v1/shape`, params: { @@ -185,7 +189,7 @@ export const electricConfigCollection = createCollection( // Query Config Collection export const queryConfigCollection = createCollection( queryCollectionOptions({ - id: `config`, + id: `query-config`, queryKey: [`config`], refetchInterval: 3000, queryFn: async () => { @@ -231,7 +235,7 @@ type Config = { // TrailBase Config Collection export const trailBaseConfigCollection = createCollection( trailBaseCollectionOptions({ - id: `config`, + id: `trailbase-config`, getKey: (item) => item.id, schema: selectConfigSchema, recordApi: trailBaseClient.records(`config`), diff --git a/examples/react/todo/src/routes/__root.tsx b/examples/react/todo/src/routes/__root.tsx index 12b4b2be4..b4f3fc20e 100644 --- a/examples/react/todo/src/routes/__root.tsx +++ b/examples/react/todo/src/routes/__root.tsx @@ -4,6 +4,8 @@ import { Scripts, createRootRoute, } from "@tanstack/react-router" +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { TanStackReactDbDevtools } from "@tanstack/react-db-devtools" import appCss from "../styles.css?url" @@ -32,6 +34,8 @@ export const Route = createRootRoute({ component: () => ( + + ), }) diff --git a/packages/db-devtools/README.md b/packages/db-devtools/README.md new file mode 100644 index 000000000..91fd548b4 --- /dev/null +++ b/packages/db-devtools/README.md @@ -0,0 +1,173 @@ +# @tanstack/db-devtools + +Developer tools for TanStack DB that provide real-time insights into your collections, live queries, and transactions. + +## Features + +- **Collection Monitoring**: View all active collections with real-time status updates +- **Live Query Insights**: Special handling for live queries with performance metrics +- **Transaction Tracking**: Monitor all database transactions and their states +- **WeakRef Architecture**: Collections are tracked without preventing garbage collection +- **Framework Agnostic**: Core devtools built with Solid.js, with React and Vue wrappers +- **Development Only**: Automatically tree-shaken in production builds + +## Installation + +```bash +# Core devtools (built with Solid.js) +npm install @tanstack/db-devtools + +# React wrapper +npm install @tanstack/react-db-devtools + +# Vue wrapper +npm install @tanstack/vue-db-devtools +``` + +## Usage + +### Core Devtools (Solid.js) + +```typescript +import { DbDevtools } from '@tanstack/db-devtools' + +// Initialize devtools (must be called before creating collections) +import { initializeDbDevtools } from '@tanstack/db-devtools' +initializeDbDevtools() + +// Use the devtools component +function App() { + return ( +
+

My App

+ +
+ ) +} +``` + +### React + +```tsx +import { ReactDbDevtools } from '@tanstack/react-db-devtools' + +function App() { + return ( +
+

My App

+ +
+ ) +} +``` + +### Vue + +```vue + + + +``` + +## Configuration + +The devtools accept the following configuration options: + +```typescript +interface DbDevtoolsConfig { + initialIsOpen?: boolean + position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'relative' + panelProps?: Record + closeButtonProps?: Record + toggleButtonProps?: Record + storageKey?: string + panelState?: 'open' | 'closed' + onPanelStateChange?: (isOpen: boolean) => void +} +``` + +## Architecture + +### Auto-Registration + +Collections automatically register themselves with the devtools when created: + +```typescript +// When devtools are initialized, this creates a window global +window.__TANSTACK_DB_DEVTOOLS__ + +// Collections check for this global and register themselves +const collection = createCollection({ + // ... config +}) +// Collection is now visible in devtools +``` + +### WeakRef Design + +The devtools use WeakRef to track collections without preventing garbage collection: + +- **Metadata polling**: Basic info (size, status) is polled every second +- **Hard references**: Only created when viewing collection details +- **Automatic cleanup**: Dead references are garbage collected + +### Live Query Detection + +Live queries are automatically detected and shown separately: + +```typescript +// This will be marked as a live query in devtools +const liveQuery = createLiveQueryCollection({ + query: (q) => q.from(collection).select() +}) +``` + +## What You Can See + +### Collections View +- Collection ID and type (regular vs live query) +- Status (idle, loading, ready, error, cleaned-up) +- Current size and transaction count +- Creation and last update timestamps +- Garbage collection settings + +### Live Queries View +- All the above plus: +- Initial run time +- Total incremental runs +- Average incremental run time +- Last incremental run time + +### Transactions View +- Transaction ID and state +- Associated collection +- Mutation details (insert, update, delete) +- Optimistic vs confirmed operations +- Creation and update timestamps + +### Collection Details +- Full collection metadata +- Live data with real-time updates +- Individual item inspection +- JSON view of all items + +## Performance + +The devtools are designed to have minimal impact on your application: + +- **Development only**: Automatically removed in production +- **WeakRef tracking**: No memory leaks from collection references +- **Efficient polling**: Only basic metadata is polled, detailed data is fetched on-demand +- **Lazy loading**: UI components are loaded only when needed + +## Browser Support + +Requires modern browsers that support WeakRef (Chrome 84+, Firefox 79+, Safari 14.1+). +Since this is development-only, this should not be a concern for production applications. \ No newline at end of file diff --git a/packages/db-devtools/eslint.config.js b/packages/db-devtools/eslint.config.js new file mode 100644 index 000000000..0c7505c86 --- /dev/null +++ b/packages/db-devtools/eslint.config.js @@ -0,0 +1,33 @@ +import prettierPlugin from "eslint-plugin-prettier" +import prettierConfig from "eslint-config-prettier" +import stylisticPlugin from "@stylistic/eslint-plugin" +import { tanstackConfig } from "@tanstack/config/eslint" + +export default [ + ...tanstackConfig, + { ignores: [`dist/`, 'build/**', 'coverage/**', 'eslint.config.js'] }, + { + plugins: { + stylistic: stylisticPlugin, + prettier: prettierPlugin, + }, + rules: { + "prettier/prettier": `error`, + "stylistic/quotes": [`error`, `backtick`], + ...prettierConfig.rules, + "no-console": "warn", + "@typescript-eslint/no-unused-vars": [ + `error`, + { argsIgnorePattern: `^_`, varsIgnorePattern: `^_` }, + ], + "@typescript-eslint/naming-convention": [ + "error", + { + selector: "typeParameter", + format: ["PascalCase"], + leadingUnderscore: `allow`, + }, + ], + }, + }, +] \ No newline at end of file diff --git a/packages/db-devtools/package.json b/packages/db-devtools/package.json new file mode 100644 index 000000000..c04c67c58 --- /dev/null +++ b/packages/db-devtools/package.json @@ -0,0 +1,93 @@ +{ + "name": "@tanstack/db-devtools", + "version": "0.0.1", + "description": "Developer tools for TanStack DB", + "author": "tanstack", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/db.git", + "directory": "packages/db-devtools" + }, + "homepage": "https://tanstack.com/db", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + ".": { + "@tanstack/custom-condition": "./src/index.ts", + "solid": { + "development": "./dist/index.js", + "import": "./dist/index.js" + }, + "development": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src", + "!src/__tests__" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "clean": "premove ./build ./coverage ./dist-ts", + "compile": "tsc --build", + "test:eslint": "eslint ./src", + "test:types": "npm-run-all --serial test:types:*", + "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build", + "test:types:tscurrent": "tsc --build", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", + "test:build": "publint --strict && attw --pack", + "lint": "eslint . --fix", + "build": "vite build", + "build:dev": "tsup --watch" + }, + "devDependencies": { + "@kobalte/core": "^0.13.4", + "@solid-primitives/keyed": "^1.2.2", + "@solid-primitives/resize-observer": "^2.0.26", + "@solid-primitives/storage": "^1.3.11", + "@tanstack/match-sorter-utils": "^8.19.4", + "clsx": "^2.1.1", + "goober": "^2.1.16", + "npm-run-all2": "^5.0.0", + "solid-js": "^1.9.5", + "solid-transition-group": "^0.2.3", + "superjson": "^2.2.1", + "tsup-preset-solid": "^2.2.0", + "vite-plugin-dts": "^4.5.4", + "vite-plugin-solid": "^2.11.6" + } +} \ No newline at end of file diff --git a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx new file mode 100644 index 000000000..2ddc6ba9b --- /dev/null +++ b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx @@ -0,0 +1,212 @@ +import { clsx as cx } from "clsx" +import { Show, createEffect, createMemo, createSignal } from "solid-js" +import { useDevtoolsOnClose } from "./contexts" +import { useStyles } from "./useStyles" +import { useLocalStorage } from "./useLocalStorage" +import { + CollectionDetailsPanel, + CollectionsPanel, + GenericDetailsPanel, + Logo, + TabNavigation, + TransactionsPanel, +} from "./components" +import type { Accessor, JSX } from "solid-js" +import type { + CollectionMetadata, + DbDevtoolsRegistry, + TransactionDetails, +} from "./types" + +export interface BaseDbDevtoolsPanelOptions { + /** + * The standard React style object used to style a component with inline styles + */ + style?: Accessor + /** + * The standard React class property used to style a component with classes + */ + className?: Accessor + /** + * A boolean variable indicating whether the panel is open or closed + */ + isOpen?: boolean + /** + * A function that toggles the open and close state of the panel + */ + setIsOpen?: (isOpen: boolean) => void + /** + * Handles the opening and closing the devtools panel + */ + handleDragStart?: (e: any) => void + /** + * The DB devtools registry instance + */ + registry: Accessor + /** + * Use this to attach the devtool's styles to specific element in the DOM. + */ + shadowDOMTarget?: ShadowRoot +} + +export const BaseTanStackDbDevtoolsPanel = + function BaseTanStackDbDevtoolsPanel({ + ...props + }: BaseDbDevtoolsPanelOptions): JSX.Element { + const { setIsOpen, handleDragStart, registry, ...panelProps } = props + + const { onCloseClick } = useDevtoolsOnClose() + const styles = useStyles() + const { className, style, ...otherPanelProps } = panelProps + + // Simple local state - no navigation store complexity + const [selectedView, setSelectedView] = createSignal< + `collections` | `transactions` + >(`collections`) + const [activeCollectionId, setActiveCollectionId] = useLocalStorage( + `tanstackDbDevtoolsActiveCollectionId`, + `` + ) + const [selectedTransaction, setSelectedTransaction] = createSignal< + string | null + >(null) + const [collections, setCollections] = createSignal< + Array + >([]) + const [transactions, setTransactions] = createSignal< + Array + >([]) + + // Computed values + const activeCollection = createMemo(() => { + const found = collections().find((c) => c.id === activeCollectionId()) + return found + }) + + const activeTransaction = createMemo(() => { + const found = transactions().find((t) => t.id === selectedTransaction()) + return found + }) + + // Use reactive signals for immediate updates + createEffect(() => { + try { + // Get collections from reactive signal + const newCollections = registry().collectionsSignal() + + // Simple auto-selection: if no collection is selected and we have collections, select the first one + if (activeCollectionId() === `` && newCollections.length > 0) { + setActiveCollectionId(newCollections[0]?.id ?? ``) + } + + // Update collections state + setCollections(newCollections) + } catch { + // console.error(`Error updating collections:`, error) + } + }) + + createEffect(() => { + try { + // Get transactions from reactive signal + const newTransactions = registry().transactionsSignal() + + // Update transactions state + setTransactions(newTransactions) + } catch { + // console.error(`Error updating transactions:`, error) + } + }) + + return ( +
+ {handleDragStart ? ( +
+ ) : null} + + + +
+
+
+ + collections().length} + transactionsCount={() => transactions().length} + onSelectView={setSelectedView} + /> +
+
+
+ {/* Content based on selected view */} +
+ + setActiveCollectionId(c.id)} + /> + + + + + +
+
+
+ +
+ + + + + + +
+
+ ) + } + +export default BaseTanStackDbDevtoolsPanel diff --git a/packages/db-devtools/src/FloatingTanStackDbDevtools.tsx b/packages/db-devtools/src/FloatingTanStackDbDevtools.tsx new file mode 100644 index 000000000..7dc489d8a --- /dev/null +++ b/packages/db-devtools/src/FloatingTanStackDbDevtools.tsx @@ -0,0 +1,274 @@ +import { clsx as cx } from "clsx" +import { createEffect, createMemo, createSignal } from "solid-js" +import { Dynamic } from "solid-js/web" +import { DevtoolsOnCloseContext } from "./contexts" +import { BaseTanStackDbDevtoolsPanel } from "./BaseTanStackDbDevtoolsPanel" +import { useLocalStorage } from "./useLocalStorage" +import { TanStackLogo } from "./logo" +import { useStyles } from "./useStyles" +import type { Accessor, JSX } from "solid-js" +import type { DbDevtoolsRegistry } from "./types" + +export interface FloatingDbDevtoolsOptions { + /** + * Set this true if you want the dev tools to default to being open + */ + initialIsOpen?: boolean + /** + * Use this to add props to the panel. For example, you can add class, style (merge and override default style), etc. + */ + panelProps?: any & { + ref?: any + } + /** + * Use this to add props to the close button. For example, you can add class, style (merge and override default style), onClick (extend default handler), etc. + */ + closeButtonProps?: any & { + ref?: any + } + /** + * Use this to add props to the toggle button. For example, you can add class, style (merge and override default style), onClick (extend default handler), etc. + */ + toggleButtonProps?: any & { + ref?: any + } + /** + * The position of the TanStack DB logo to open and close the devtools panel. + * Defaults to 'bottom-left'. + */ + position?: `top-left` | `top-right` | `bottom-left` | `bottom-right` + /** + * Use this to render the devtools inside a different type of container element for a11y purposes. + * Any string which corresponds to a valid intrinsic JSX element is allowed. + * Defaults to 'footer'. + */ + containerElement?: string | any + /** + * The DB devtools registry instance + */ + registry: Accessor + /** + * Use this to attach the devtool's styles to specific element in the DOM. + */ + shadowDOMTarget?: ShadowRoot +} + +export function FloatingTanStackDbDevtools({ + initialIsOpen, + panelProps = {}, + closeButtonProps = {}, + toggleButtonProps = {}, + position = `bottom-left`, + containerElement: Container = `footer`, + registry, + shadowDOMTarget, +}: FloatingDbDevtoolsOptions): JSX.Element | null { + const [rootEl, setRootEl] = createSignal() + + // eslint-disable-next-line prefer-const + let panelRef: HTMLDivElement | undefined = undefined + + const [isOpen, setIsOpen] = useLocalStorage( + `tanstackDbDevtoolsOpen`, + initialIsOpen + ) + + const [devtoolsHeight, setDevtoolsHeight] = useLocalStorage( + `tanstackDbDevtoolsHeight`, + null + ) + + const [isResolvedOpen, setIsResolvedOpen] = createSignal(false) + const [isResizing, setIsResizing] = createSignal(false) + const styles = useStyles() + + const handleDragStart = ( + panelElement: HTMLDivElement | undefined, + startEvent: any + ) => { + if (startEvent.button !== 0) return // Only allow left click for drag + + setIsResizing(true) + + const dragInfo = { + originalHeight: panelElement?.getBoundingClientRect().height || 0, + pageY: startEvent.pageY, + } + + const run = (moveEvent: MouseEvent) => { + const delta = dragInfo.pageY - moveEvent.pageY + const newHeight = dragInfo.originalHeight + delta + + setDevtoolsHeight(newHeight) + + if (newHeight < 70) { + setIsOpen(false) + } else { + setIsOpen(true) + } + } + + const unsub = () => { + setIsResizing(false) + document.removeEventListener(`mousemove`, run) + document.removeEventListener(`mouseUp`, unsub) + } + + document.addEventListener(`mousemove`, run) + document.addEventListener(`mouseup`, unsub) + } + + createEffect(() => { + setIsResolvedOpen(isOpen()) + }) + + createEffect(() => { + if (isResolvedOpen()) { + const previousValue = rootEl()?.parentElement?.style.paddingBottom + + const run = () => { + const containerHeight = panelRef!.getBoundingClientRect().height + if (rootEl()?.parentElement) { + setRootEl((prev) => { + if (prev?.parentElement) { + prev.parentElement.style.paddingBottom = `${containerHeight}px` + } + return prev + }) + } + } + + run() + + if (typeof window !== `undefined`) { + window.addEventListener(`resize`, run) + + return () => { + window.removeEventListener(`resize`, run) + if (rootEl()?.parentElement && typeof previousValue === `string`) { + setRootEl((prev) => { + prev!.parentElement!.style.paddingBottom = previousValue + return prev + }) + } + } + } + } else { + // Reset padding when devtools are closed + if (rootEl()?.parentElement) { + setRootEl((prev) => { + if (prev?.parentElement) { + prev.parentElement.removeAttribute(`style`) + } + return prev + }) + } + } + return + }) + + createEffect(() => { + if (rootEl()) { + const el = rootEl() + const fontSize = getComputedStyle(el!).fontSize + el?.style.setProperty(`--tsdb-font-size`, fontSize) + } + }) + + const { style: panelStyle = {}, ...otherPanelProps } = panelProps as { + style?: Record + } + + const { onClick: onCloseClick } = closeButtonProps + + const { + onClick: onToggleClick, + class: toggleButtonClassName, + ...otherToggleButtonProps + } = toggleButtonProps + + // Always render when called (we're already in a client-side React environment) + // The original isMounted() check was preventing rendering when embedded in React + // if (!isMounted()) return null + + const resolvedHeight = createMemo(() => { + const h = devtoolsHeight() + return typeof h === `number` ? h : 500 + }) + + const basePanelClass = createMemo(() => { + return cx( + styles().devtoolsPanelContainer, + styles().devtoolsPanelContainerVisibility(!!isOpen()), + styles().devtoolsPanelContainerResizing(isResizing), + styles().devtoolsPanelContainerAnimation( + isResolvedOpen(), + resolvedHeight() + 16 + ) + ) + }) + + const basePanelStyle = createMemo(() => { + return { + height: `${resolvedHeight()}px`, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...(panelStyle || {}), + } + }) + + const buttonStyle = createMemo(() => { + return cx( + styles().mainCloseBtn, + styles().mainCloseBtnPosition(position), + styles().mainCloseBtnAnimation(!!isOpen()), + toggleButtonClassName + ) + }) + + return ( + + {}, + }} + > + handleDragStart(panelRef, e)} + shadowDOMTarget={shadowDOMTarget} + /> + + + + + ) +} + +export default FloatingTanStackDbDevtools diff --git a/packages/db-devtools/src/TanstackDbDevtools.tsx b/packages/db-devtools/src/TanstackDbDevtools.tsx new file mode 100644 index 000000000..58b7f0c09 --- /dev/null +++ b/packages/db-devtools/src/TanstackDbDevtools.tsx @@ -0,0 +1,126 @@ +/** @jsxImportSource solid-js */ +import { render } from "solid-js/web" +import { createSignal } from "solid-js" +import { initializeDevtoolsRegistry } from "./registry" +import { FloatingTanStackDbDevtools } from "./FloatingTanStackDbDevtools" +import type { DbDevtoolsConfig, DbDevtoolsRegistry } from "./types" +import type { Signal } from "solid-js" + +export interface TanstackDbDevtoolsConfig extends DbDevtoolsConfig { + styleNonce?: string + shadowDOMTarget?: ShadowRoot +} + +class TanstackDbDevtools { + #registry: DbDevtoolsRegistry + #isMounted = false + #shadowDOMTarget?: ShadowRoot + #initialIsOpen: Signal + #position: Signal + #panelProps: Signal | undefined> + #toggleButtonProps: Signal | undefined> + #closeButtonProps: Signal | undefined> + #storageKey: Signal + #panelState: Signal + #onPanelStateChange: Signal<((isOpen: boolean) => void) | undefined> + #dispose?: () => void + + constructor(config: TanstackDbDevtoolsConfig) { + const { + initialIsOpen, + position, + panelProps, + toggleButtonProps, + closeButtonProps, + storageKey, + panelState, + onPanelStateChange, + styleNonce: _styleNonce, + shadowDOMTarget, + } = config + + this.#registry = initializeDevtoolsRegistry() + this.#shadowDOMTarget = shadowDOMTarget + this.#initialIsOpen = createSignal(initialIsOpen) + this.#position = createSignal(position) + this.#panelProps = createSignal(panelProps) + this.#toggleButtonProps = createSignal(toggleButtonProps) + this.#closeButtonProps = createSignal(closeButtonProps) + this.#storageKey = createSignal(storageKey) + this.#panelState = createSignal(panelState) + this.#onPanelStateChange = createSignal(onPanelStateChange) + } + + setInitialIsOpen(isOpen: boolean) { + this.#initialIsOpen[1](isOpen) + } + + setPosition(position: DbDevtoolsConfig[`position`]) { + this.#position[1](position) + } + + setPanelProps(props: Record) { + this.#panelProps[1](props) + } + + setToggleButtonProps(props: Record) { + this.#toggleButtonProps[1](props) + } + + setCloseButtonProps(props: Record) { + this.#closeButtonProps[1](props) + } + + setStorageKey(key: string) { + this.#storageKey[1](key) + } + + setPanelState(state: DbDevtoolsConfig[`panelState`]) { + this.#panelState[1](state) + } + + setOnPanelStateChange(callback: (isOpen: boolean) => void) { + this.#onPanelStateChange[1](() => callback) + } + + mount(el: T) { + if (this.#isMounted) { + throw new Error(`DB Devtools is already mounted`) + } + + const getValidPosition = (pos: DbDevtoolsConfig[`position`]) => { + if (pos === `relative` || pos === undefined) { + return `bottom-left` as const + } + return pos + } + + const dispose = render( + () => ( + this.#registry} + shadowDOMTarget={this.#shadowDOMTarget} + /> + ), + el + ) + + this.#isMounted = true + this.#dispose = dispose + } + + unmount() { + if (!this.#isMounted) { + throw new Error(`DB Devtools is not mounted`) + } + this.#dispose?.() + this.#isMounted = false + } +} + +export { TanstackDbDevtools } diff --git a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx new file mode 100644 index 000000000..d8ac93e7c --- /dev/null +++ b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx @@ -0,0 +1,196 @@ +import { Show, createMemo, createSignal } from "solid-js" +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" +import { getDevtoolsRegistry } from "../devtools" +import { Explorer } from "./Explorer" +import { TransactionsPanel } from "./TransactionsPanel" +import { GenericDetailsPanel } from "./DetailsPanel" +import type { CollectionMetadata } from "../types" +import type { Accessor } from "solid-js" + +export interface CollectionDetailsPanelProps { + activeCollection: Accessor +} + +type CollectionTab = `summary` | `config` | `state` | `transactions` | `data` + +export function CollectionDetailsPanel({ + activeCollection, +}: CollectionDetailsPanelProps) { + const styles = useStyles() + const [selectedTab, setSelectedTab] = createSignal(`summary`) + const [selectedTransaction, setSelectedTransaction] = createSignal< + string | null + >(null) + const registry = getDevtoolsRegistry() + + const collection = createMemo(() => { + const metadata = activeCollection() + if (!metadata || !registry) return null + + // Get the actual collection instance + const collectionInstance = registry.getCollection(metadata.id) + return { metadata, instance: collectionInstance } + }) + + const collectionTransactions = createMemo(() => { + const metadata = activeCollection() + if (!metadata || !registry) return [] + + return registry.getTransactions(metadata.id) + }) + + const activeTransaction = createMemo(() => { + const transactions = collectionTransactions() + const selectedId = selectedTransaction() + return transactions.find((t) => t.id === selectedId) + }) + + const tabs: Array<{ id: CollectionTab; label: string }> = [ + { id: `summary`, label: `Summary` }, + { id: `config`, label: `Config` }, + { id: `state`, label: `State` }, + { id: `transactions`, label: `Transactions` }, + { id: `data`, label: `Data` }, + ] + + const renderTabContent = () => { + const currentCollection = collection() + if (!currentCollection) return null + + const { metadata, instance } = currentCollection + + switch (selectedTab()) { + case `summary`: { + return ( + metadata} + defaultExpanded={{}} + /> + ) + } + + case `config`: { + return instance ? ( + instance.config} + defaultExpanded={{}} + /> + ) : ( +
+ Collection instance not available +
+ ) + } + + case `state`: { + if (!instance) { + return ( +
+ Collection instance not available +
+ ) + } + + const stateData = { + syncedData: instance.syncedData, + optimisticUpserts: instance.optimisticUpserts, + optimisticDeletes: instance.optimisticDeletes, + } + + return ( + stateData} + defaultExpanded={{}} + /> + ) + } + + case `transactions`: { + const transactions = collectionTransactions() + return ( +
+
+ transactions} + selectedTransaction={selectedTransaction} + onSelectTransaction={setSelectedTransaction} + /> +
+
+ `transactions` as const} + activeCollection={() => undefined} + activeTransaction={activeTransaction} + isSubPanel={true} + /> +
+
+ ) + } + + case `data`: { + return ( +
Grid view coming soon...
+ ) + } + + default: + return null + } + } + + return ( + +
+ Select a collection to view details +
+ + } + > + {(collectionMetadata) => ( +
+
+
{collectionMetadata().id}
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {renderTabContent()} +
+
+ )} +
+ ) +} diff --git a/packages/db-devtools/src/components/CollectionItem.tsx b/packages/db-devtools/src/components/CollectionItem.tsx new file mode 100644 index 000000000..a3d7fd148 --- /dev/null +++ b/packages/db-devtools/src/components/CollectionItem.tsx @@ -0,0 +1,32 @@ +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" +import { CollectionStats } from "./CollectionStats" +import type { Accessor } from "solid-js" +import type { CollectionMetadata } from "../types" + +interface CollectionItemProps { + collection: CollectionMetadata + isActive: Accessor + onSelect: (collection: CollectionMetadata) => void +} + +export function CollectionItem({ + collection, + isActive, + onSelect, +}: CollectionItemProps) { + const styles = useStyles() + + return ( +
onSelect(collection)} + > +
{collection.id}
+ +
+ ) +} diff --git a/packages/db-devtools/src/components/CollectionStats.tsx b/packages/db-devtools/src/components/CollectionStats.tsx new file mode 100644 index 000000000..eb1abcc83 --- /dev/null +++ b/packages/db-devtools/src/components/CollectionStats.tsx @@ -0,0 +1,52 @@ +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" +import { formatTime } from "../utils/formatTime" +import type { CollectionMetadata } from "../types" + +interface CollectionStatsProps { + collection: CollectionMetadata +} + +export function CollectionStats({ collection }: CollectionStatsProps) { + const styles = useStyles() + + if (collection.type === `collection`) { + // Standard collection stats + return ( +
+
{collection.size}
+
/
+
{collection.transactionCount}
+
/
+
{formatTime(collection.gcTime || 0)}
+
/
+
+ {collection.status} +
+
+ ) + } else { + // Live query collection stats + return ( +
+
{collection.size}
+
/
+
{formatTime(collection.gcTime || 0)}
+
/
+
+ {collection.status} +
+
+ ) + } +} diff --git a/packages/db-devtools/src/components/CollectionsPanel.tsx b/packages/db-devtools/src/components/CollectionsPanel.tsx new file mode 100644 index 000000000..6177405c9 --- /dev/null +++ b/packages/db-devtools/src/components/CollectionsPanel.tsx @@ -0,0 +1,146 @@ +import { For, Show, createMemo } from "solid-js" +import { useStyles } from "../useStyles" +import { multiSortBy } from "../utils" +import { CollectionItem } from "./CollectionItem" +import type { Accessor } from "solid-js" +import type { CollectionMetadata } from "../types" + +interface CollectionsPanelProps { + collections: Accessor> + activeCollectionId: Accessor + onSelectCollection: (collection: CollectionMetadata) => void +} + +export function CollectionsPanel({ + collections, + activeCollectionId, + onSelectCollection, +}: CollectionsPanelProps) { + const styles = useStyles() + + const sortedCollections = createMemo(() => { + return multiSortBy(collections(), [ + (c) => (c.status === `error` ? 0 : 1), // Errors first + (c) => c.id.toLowerCase(), // Then alphabetically by ID + ]) + }) + + // Group collections by type + const groupedCollections = createMemo(() => { + const groups: Record> = {} + + sortedCollections().forEach((collection) => { + const type = collection.type + if (!groups[type]) { + groups[type] = [] + } + const targetGroup = groups[type] + targetGroup.push(collection) + }) + + // Sort collections within each group alphabetically + Object.keys(groups).forEach((key) => { + const group = groups[key] + if (group) { + group.sort((a, b) => + a.id.toLowerCase().localeCompare(b.id.toLowerCase()) + ) + } + }) + + return groups + }) + + const getGroupDisplayName = (type: string): string => { + switch (type) { + case `live-query`: + return `Live Queries` + case `electric`: + return `Electric Collections` + case `query`: + return `Query Collections` + case `local-only`: + return `Local-Only Collections` + case `local-storage`: + return `Local Storage Collections` + case `generic`: + return `Generic Collections` + default: + return `${type.charAt(0).toUpperCase() + type.slice(1)} Collections` + } + } + + const getGroupStats = (type: string): Array => { + switch (type) { + case `live-query`: + return [`Items`, `/`, `GC`, `/`, `Status`] + case `electric`: + return [`Items`, `/`, `Txn`, `/`, `GC`, `/`, `Status`] + case `query`: + return [`Items`, `/`, `Txn`, `/`, `GC`, `/`, `Status`] + case `local-only`: + return [`Items`, `/`, `Txn`, `/`, `GC`, `/`, `Status`] + case `local-storage`: + return [`Items`, `/`, `Txn`, `/`, `GC`, `/`, `Status`] + case `generic`: + return [`Items`, `/`, `Txn`, `/`, `GC`, `/`, `Status`] + default: + return [`Items`, `/`, `Txn`, `/`, `GC`, `/`, `Status`] + } + } + + // Get sorted group entries with live-query first, then alphabetical + const sortedGroupEntries = createMemo(() => { + const entries = Object.entries(groupedCollections()) + return entries.sort(([a], [b]) => { + // Live-query always comes first + if (a === `live-query`) return -1 + if (b === `live-query`) return 1 + // Others are sorted alphabetically + return a.localeCompare(b) + }) + }) + + return ( +
+
+ 0} + fallback={ +
+ No collections found +
+ } + > + + {([type, groupCollections]) => ( + 0}> +
+
+
+ {getGroupDisplayName(type)} ({groupCollections.length}) +
+
+ + {(stat) => {stat}} + +
+
+ + {(collection) => ( + collection.id === activeCollectionId()} + onSelect={onSelectCollection} + /> + )} + +
+
+ )} +
+
+
+
+ ) +} diff --git a/packages/db-devtools/src/components/DetailsPanel.tsx b/packages/db-devtools/src/components/DetailsPanel.tsx new file mode 100644 index 000000000..7feda39ec --- /dev/null +++ b/packages/db-devtools/src/components/DetailsPanel.tsx @@ -0,0 +1,168 @@ +import { Show, createMemo } from "solid-js" +import { useStyles } from "../useStyles" +import { Explorer } from "./Explorer" +import type { CollectionMetadata, TransactionDetails } from "../types" +import type { Accessor } from "solid-js" + +export interface DetailsPanelProps { + selectedView: Accessor<`collections` | `transactions`> + activeCollection: Accessor + activeTransaction: Accessor + isSubPanel?: boolean +} + +export function DetailsPanel({ + selectedView, + activeCollection, + activeTransaction: _activeTransaction, +}: DetailsPanelProps) { + const styles = useStyles() + + return ( + + +
+ Select a collection to view details +
+ + } + > + {(collection) => ( +
+
{collection().id}
+
+ collection()} + defaultExpanded={{}} + /> +
+
+ )} +
+
+ ) +} + +export function TransactionDetailsPanel({ + selectedView, + activeTransaction: _activeTransaction, +}: DetailsPanelProps) { + const styles = useStyles() + + return ( + + +
+ Select a transaction to view details +
+ + } + > + {(transaction) => ( +
+
+ Transaction {transaction().id} +
+
+ transaction()} + defaultExpanded={{}} + /> +
+
+ )} +
+
+ ) +} + +export function GenericDetailsPanel({ + selectedView, + activeCollection, + activeTransaction, + isSubPanel = false, +}: DetailsPanelProps) { + const styles = useStyles() + + // Create stable value functions using createMemo to prevent unnecessary re-renders + const collectionValue = createMemo(() => activeCollection()) + const transactionValue = createMemo(() => activeTransaction()) + + return ( + <> + + +
+ Select a collection to view details +
+ + } + > + {(collection) => ( +
+
{collection().id}
+
+ +
+
+ )} +
+
+ + + +
+ Select a transaction to view details +
+ + } + > + {(transaction) => ( +
+
+ Transaction {transaction().id} +
+
+ +
+
+ )} +
+
+ + ) +} diff --git a/packages/db-devtools/src/components/Explorer.tsx b/packages/db-devtools/src/components/Explorer.tsx new file mode 100644 index 000000000..e1a3b97ad --- /dev/null +++ b/packages/db-devtools/src/components/Explorer.tsx @@ -0,0 +1,361 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { clsx as cx } from "clsx" +import * as goober from "goober" +import { createMemo, createSignal, useContext } from "solid-js" +import { tokens } from "../tokens" +import { ShadowDomTargetContext } from "../contexts" +import type { Accessor, JSX } from "solid-js" + +type ExpanderProps = { + expanded: boolean + style?: JSX.CSSProperties +} + +export const Expander = ({ expanded, style: _style = {} }: ExpanderProps) => { + const styles = useStyles() + return ( + + + + + + ) +} + +type Entry = { + label: string +} + +type RendererProps = { + handleEntry: HandleEntryFn + label?: JSX.Element + value: Accessor + subEntries: Array + subEntryPages: Array> + type: string + expanded: Accessor + toggleExpanded: () => void + pageSize: number + filterSubEntries?: (subEntries: Array) => Array +} + +/** + * Chunk elements in the array by size + * + * when the array cannot be chunked evenly by size, the last chunk will be + * filled with the remaining elements + * + * @example + * chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']] + */ +export function chunkArray(array: Array, size: number): Array> { + if (size < 1) return [] + let i = 0 + const result: Array> = [] + while (i < array.length) { + result.push(array.slice(i, i + size)) + i = i + size + } + return result +} + +type HandleEntryFn = (entry: Entry) => JSX.Element + +type ExplorerProps = Partial & { + defaultExpanded?: true | Record + value: Accessor +} + +type Property = { + defaultExpanded?: boolean | Record + label: string + value: unknown +} + +function isIterable(x: any): x is Iterable { + return Symbol.iterator in x +} + +function displayValue(value: unknown): string { + if (value === null) return `null` + if (value === undefined) return `undefined` + if (typeof value === `string`) return `"${value}"` + if (typeof value === `number`) return value.toString() + if (typeof value === `boolean`) return value.toString() + if (typeof value === `function`) return `function` + if (value instanceof Date) return `Date('${value.toISOString()}')` + if (value instanceof Map) return `Map(${value.size})` + if (value instanceof Set) return `Set(${value.size})` + if (Array.isArray(value)) return `Array(${value.length})` + if (typeof value === `object`) return `Object` + return String(value) +} + +export function Explorer({ + value, + defaultExpanded, + pageSize = 100, + filterSubEntries, + ...rest +}: ExplorerProps) { + const [expanded, setExpanded] = createSignal(Boolean(defaultExpanded)) + const toggleExpanded = () => setExpanded((old) => !old) + + const type = createMemo(() => typeof value()) + const subEntries = createMemo(() => { + let entries: Array = [] + + const makeProperty = (sub: { label: string; value: unknown }): Property => { + const subDefaultExpanded = + defaultExpanded === true + ? { [sub.label]: true } + : defaultExpanded?.[sub.label] + return { + ...sub, + value: () => sub.value, + defaultExpanded: subDefaultExpanded, + } + } + + if (Array.isArray(value())) { + // any[] + entries = (value() as Array).map((d, i) => + makeProperty({ + label: i.toString(), + value: d, + }) + ) + } else if (value() instanceof Map) { + // Map + entries = Array.from((value() as Map).entries()).map( + ([key, val]) => + makeProperty({ + label: String(key), + value: val, + }) + ) + } else if (value() instanceof Set) { + // Set + entries = Array.from(value() as Set, (val, i) => + makeProperty({ + label: i.toString(), + value: val, + }) + ) + } else if ( + value() !== null && + typeof value() === `object` && + isIterable(value()) && + typeof (value() as Iterable)[Symbol.iterator] === `function` + ) { + // Iterable + entries = Array.from(value() as Iterable, (val, i) => + makeProperty({ + label: i.toString(), + value: val, + }) + ) + } else if (typeof value() === `object` && value() !== null) { + // object + entries = Object.entries(value() as object).map(([key, val]) => + makeProperty({ + label: key, + value: val, + }) + ) + } + + return filterSubEntries ? filterSubEntries(entries) : entries + }) + + const subEntryPages = createMemo(() => chunkArray(subEntries(), pageSize)) + + const [expandedPages, setExpandedPages] = createSignal>([]) + const styles = useStyles() + + // const refreshValueSnapshot = () => { + // setValueSnapshot((value() as () => any)()) + // } + + const handleEntry = (entry: Entry) => ( + + ) + + return ( +
+ {subEntryPages().length ? ( + <> + + {(expanded() ?? false) ? ( + subEntryPages().length === 1 ? ( +
+ {subEntries().map((entry, _index) => handleEntry(entry))} +
+ ) : ( +
+ {subEntryPages().map((entries, index) => { + return ( +
+
+ + {expandedPages().includes(index) ? ( +
+ {entries.map((entry) => handleEntry(entry))} +
+ ) : null} +
+
+ ) + })} +
+ ) + ) : null} + + ) : type() === `function` ? ( + <> + {rest.label}: + {` `} + {displayValue(value())} + + ) : ( + <> + {rest.label}: + {` `} + {displayValue(value())} + + )} +
+ ) +} + +const stylesFactory = (shadowDOMTarget?: ShadowRoot) => { + const { colors, font, size } = tokens + const { fontFamily, lineHeight, size: fontSize } = font + const css = shadowDOMTarget + ? goober.css.bind({ target: shadowDOMTarget }) + : goober.css + + return { + entry: css` + font-family: ${fontFamily.mono}; + font-size: ${fontSize.xs}; + line-height: ${lineHeight.sm}; + outline: none; + word-break: break-word; + `, + labelButton: css` + cursor: pointer; + color: inherit; + font: inherit; + outline: inherit; + background: transparent; + border: none; + padding: 0; + `, + expander: css` + display: inline-flex; + align-items: center; + justify-content: center; + width: ${size[3]}; + height: ${size[3]}; + padding-left: 3px; + box-sizing: content-box; + `, + expanderIcon: (expanded: boolean) => { + if (expanded) { + return css` + transform: rotate(90deg); + transition: transform 0.1s ease; + ` + } + return css` + transform: rotate(0deg); + transition: transform 0.1s ease; + ` + }, + expandButton: css` + display: flex; + gap: ${size[1]}; + align-items: center; + cursor: pointer; + color: inherit; + font: inherit; + outline: inherit; + background: transparent; + border: none; + padding: 0; + `, + value: css` + color: ${colors.purple[400]}; + `, + subEntries: css` + margin-left: ${size[2]}; + padding-left: ${size[2]}; + border-left: 2px solid ${colors.darkGray[400]}; + `, + info: css` + color: ${colors.gray[500]}; + font-size: ${fontSize.xs}; + padding-left: ${size[1]}; + `, + refreshValueBtn: css` + appearance: none; + border: 0; + cursor: pointer; + background: transparent; + color: inherit; + padding: 0; + font-family: ${fontFamily.mono}; + font-size: ${fontSize.xs}; + `, + } +} + +function useStyles() { + const shadowDOMTarget = useContext(ShadowDomTargetContext) + const styles = stylesFactory(shadowDOMTarget) + return () => styles +} diff --git a/packages/db-devtools/src/components/Logo.tsx b/packages/db-devtools/src/components/Logo.tsx new file mode 100644 index 000000000..6087c9578 --- /dev/null +++ b/packages/db-devtools/src/components/Logo.tsx @@ -0,0 +1,18 @@ +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" + +interface LogoProps { + className?: () => string + [key: string]: any +} + +export function Logo(props: LogoProps) { + const { className, ...rest } = props + const styles = useStyles() + return ( + + ) +} diff --git a/packages/db-devtools/src/components/TabNavigation.tsx b/packages/db-devtools/src/components/TabNavigation.tsx new file mode 100644 index 000000000..ebb8d49e5 --- /dev/null +++ b/packages/db-devtools/src/components/TabNavigation.tsx @@ -0,0 +1,46 @@ +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" +import type { Accessor } from "solid-js" + +interface TabNavigationProps { + selectedView: Accessor<`collections` | `transactions`> + collectionsCount: Accessor + transactionsCount: Accessor + onSelectView: (view: `collections` | `transactions`) => void +} + +export function TabNavigation({ + selectedView, + collectionsCount, + transactionsCount, + onSelectView, +}: TabNavigationProps) { + const styles = useStyles() + + return ( +
+ + +
+ ) +} diff --git a/packages/db-devtools/src/components/TransactionItem.tsx b/packages/db-devtools/src/components/TransactionItem.tsx new file mode 100644 index 000000000..0eb0ad31e --- /dev/null +++ b/packages/db-devtools/src/components/TransactionItem.tsx @@ -0,0 +1,31 @@ +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" +import { TransactionStats } from "./TransactionStats" +import type { TransactionDetails } from "../types" + +interface TransactionItemProps { + transaction: TransactionDetails + isActive: boolean + onSelect: (transactionId: string) => void +} + +export function TransactionItem({ + transaction, + isActive, + onSelect, +}: TransactionItemProps) { + const styles = useStyles() + + return ( +
onSelect(transaction.id)} + > +
{transaction.id}
+ +
+ ) +} diff --git a/packages/db-devtools/src/components/TransactionStats.tsx b/packages/db-devtools/src/components/TransactionStats.tsx new file mode 100644 index 000000000..92b363be7 --- /dev/null +++ b/packages/db-devtools/src/components/TransactionStats.tsx @@ -0,0 +1,33 @@ +import { clsx as cx } from "clsx" +import { useStyles } from "../useStyles" +import { formatTime } from "../utils/formatTime" +import type { TransactionDetails } from "../types" + +interface TransactionStatsProps { + transaction: TransactionDetails +} + +export function TransactionStats({ transaction }: TransactionStatsProps) { + const styles = useStyles() + + const age = Date.now() - transaction.createdAt.getTime() + + return ( +
+
{transaction.mutations.length}
+
/
+
1
+
/
+
{formatTime(age)}
+
/
+
+ {transaction.state} +
+
+ ) +} diff --git a/packages/db-devtools/src/components/TransactionsPanel.tsx b/packages/db-devtools/src/components/TransactionsPanel.tsx new file mode 100644 index 000000000..8fe20fcd2 --- /dev/null +++ b/packages/db-devtools/src/components/TransactionsPanel.tsx @@ -0,0 +1,58 @@ +import { For, Show } from "solid-js" +import { useStyles } from "../useStyles" +import { TransactionItem } from "./TransactionItem" +import type { Accessor } from "solid-js" +import type { TransactionDetails } from "../types" + +interface TransactionsPanelProps { + transactions: Accessor> + selectedTransaction: Accessor + onSelectTransaction: (transactionId: string) => void +} + +export function TransactionsPanel({ + transactions, + selectedTransaction, + onSelectTransaction, +}: TransactionsPanelProps) { + const styles = useStyles() + + return ( +
+
+
+
+
Transactions
+
+ Mutations + / + Collections + / + Age + / + Status +
+
+ 0} + fallback={ +
+ No transactions found +
+ } + > + + {(transaction) => ( + + )} + +
+
+
+
+ ) +} diff --git a/packages/db-devtools/src/components/index.ts b/packages/db-devtools/src/components/index.ts new file mode 100644 index 000000000..34f5c4d21 --- /dev/null +++ b/packages/db-devtools/src/components/index.ts @@ -0,0 +1,11 @@ +export { CollectionsPanel } from "./CollectionsPanel" +export { DetailsPanel, GenericDetailsPanel } from "./DetailsPanel" +export { Explorer } from "./Explorer" +export { Logo } from "./Logo" +export { TabNavigation } from "./TabNavigation" +export { TransactionItem } from "./TransactionItem" +export { TransactionStats } from "./TransactionStats" +export { TransactionsPanel } from "./TransactionsPanel" +export { CollectionItem } from "./CollectionItem" +export { CollectionStats } from "./CollectionStats" +export { CollectionDetailsPanel } from "./CollectionDetailsPanel" diff --git a/packages/db-devtools/src/constants.ts b/packages/db-devtools/src/constants.ts new file mode 100644 index 000000000..9ebb21f9a --- /dev/null +++ b/packages/db-devtools/src/constants.ts @@ -0,0 +1,30 @@ +export const DEFAULT_HEIGHT = 500 +export const DEFAULT_WIDTH = 500 +export const POSITION = `bottom-right` +export const BUTTON_POSITION = `bottom-right` +export const INITIAL_IS_OPEN = false +export const DEFAULT_SORT_ORDER = 1 +export const DEFAULT_SORT_FN_NAME = `Status > Last Updated` +export const DEFAULT_MUTATION_SORT_FN_NAME = `Status > Last Updated` + +export const firstBreakpoint = 1024 +export const secondBreakpoint = 796 +export const thirdBreakpoint = 700 + +export type DevtoolsPosition = + | `top-left` + | `top-right` + | `bottom-left` + | `bottom-right` + | `top` + | `bottom` + | `left` + | `right` +export type DevtoolsButtonPosition = + | `top-left` + | `top-right` + | `bottom-left` + | `bottom-right` + | `relative` + +export const isServer = typeof window === `undefined` diff --git a/packages/db-devtools/src/contexts/NavigationContext.tsx b/packages/db-devtools/src/contexts/NavigationContext.tsx new file mode 100644 index 000000000..839e26bf9 --- /dev/null +++ b/packages/db-devtools/src/contexts/NavigationContext.tsx @@ -0,0 +1,108 @@ +import { createContext, createSignal, useContext } from "solid-js" +import type { Accessor, Setter } from "solid-js" +import type { CollectionMetadata, TransactionDetails } from "../types" + +export interface NavigationState { + selectedView: Accessor<`collections` | `transactions`> + setSelectedView: Setter<`collections` | `transactions`> + activeCollectionId: Accessor + setActiveCollectionId: Setter + selectedTransaction: Accessor + setSelectedTransaction: Setter + activeCollection: Accessor + activeTransaction: Accessor + collections: Accessor> + setCollections: Setter> + transactions: Accessor> + setTransactions: Setter> +} + +const NavigationContext = createContext() + +export function createNavigationStore(): NavigationState { + const [selectedView, setSelectedView] = createSignal< + `collections` | `transactions` + >(`collections`) + const [activeCollectionId, setActiveCollectionId] = createSignal(``) + const [selectedTransaction, setSelectedTransaction] = createSignal< + string | null + >(null) + + // These will be set by the parent component + const [collections, setCollections] = createSignal>( + [] + ) + const [transactions, setTransactions] = createSignal< + Array + >([]) + + const activeCollection = () => { + const active = collections().find((c) => c.id === activeCollectionId()) + return active + } + + const activeTransaction = () => { + const active = transactions().find((t) => t.id === selectedTransaction()) + return active + } + + // Debug logging + const debugSetSelectedView: Setter<`collections` | `transactions`> = ( + value + ) => { + setSelectedView(value) + } + + const debugSetActiveCollectionId: Setter = (value) => { + setActiveCollectionId(value) + } + + const debugSetSelectedTransaction: Setter = (value) => { + setSelectedTransaction(value) + } + + const debugSetCollections: Setter> = (value) => { + setCollections(value) + } + + const debugSetTransactions: Setter> = (value) => { + setTransactions(value) + } + + const store: NavigationState = { + selectedView, + setSelectedView: debugSetSelectedView, + activeCollectionId, + setActiveCollectionId: debugSetActiveCollectionId, + selectedTransaction, + setSelectedTransaction: debugSetSelectedTransaction, + activeCollection, + activeTransaction, + // Internal state setters for parent component + collections, + setCollections: debugSetCollections, + transactions, + setTransactions: debugSetTransactions, + } + + return store +} + +export function useNavigation() { + const context = useContext(NavigationContext) + if (!context) { + throw new Error(`useNavigation must be used within a NavigationProvider`) + } + return context +} + +export function NavigationProvider(props: { + children: any + store: NavigationState +}) { + return ( + + {props.children} + + ) +} diff --git a/packages/db-devtools/src/contexts/index.tsx b/packages/db-devtools/src/contexts/index.tsx new file mode 100644 index 000000000..518b202ff --- /dev/null +++ b/packages/db-devtools/src/contexts/index.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from "solid-js" + +// Devtools On Close Context - matches Router devtools pattern +export const DevtoolsOnCloseContext = createContext<{ + onCloseClick: (e: any) => void +}>({ + onCloseClick: () => {}, +}) + +export const useDevtoolsOnClose = () => useContext(DevtoolsOnCloseContext) + +// Shadow DOM Target Context - matches Router devtools pattern +export const ShadowDomTargetContext = createContext( + undefined +) + +// Navigation Context +export * from "./NavigationContext" diff --git a/packages/db-devtools/src/devtools.ts b/packages/db-devtools/src/devtools.ts new file mode 100644 index 000000000..56dba3c84 --- /dev/null +++ b/packages/db-devtools/src/devtools.ts @@ -0,0 +1,153 @@ +import { initializeDevtoolsRegistry } from "./registry" +import type { CollectionImpl } from "../../db/src/collection" +import type { DbDevtoolsRegistry } from "./types" + +/** + * Initialize the DB devtools registry. + * This should be called once in your application, typically in your main entry point. + * Collections will automatically register themselves if this registry is present. + */ +export function initializeDbDevtools(): void { + // SSR safety check + if (typeof window === `undefined`) { + return + } + + // Check if devtools are already initialized + if ((window as any).__TANSTACK_DB_DEVTOOLS__) { + return + } + + // Initialize the registry + const registry = initializeDevtoolsRegistry() + + // Store the registry globally under the namespaced structure + ;(window as any).__TANSTACK_DB_DEVTOOLS__ = { + ...registry, + collectionsSignal: registry.collectionsSignal, + transactionsSignal: registry.transactionsSignal, + registerCollection: (collection: any) => { + const updateCallback = registry.registerCollection(collection) + // Store the callback on the collection for later use + if (updateCallback && collection) { + collection.__devtoolsUpdateCallback = updateCallback + } + }, + unregisterCollection: (id: string) => { + registry.unregisterCollection(id) + }, + } +} + +/** + * Manually register a collection with the devtools. + * This is automatically called by collections when they are created if devtools are enabled. + */ +export function registerCollection( + collection: CollectionImpl | undefined +): void { + if (typeof window === `undefined`) return + + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ as { + registerCollection: (collection: any) => (() => void) | undefined + } + const updateCallback: (() => void) | undefined = + devtools.registerCollection(collection) + // Store the callback on the collection for later use + if (updateCallback && collection) { + ;(collection as any).__devtoolsUpdateCallback = updateCallback + } +} + +/** + * Manually unregister a collection from the devtools. + * This is automatically called when collections are garbage collected. + */ +export function unregisterCollection(id: string): void { + if (typeof window === `undefined`) return + + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ as { + unregisterCollection: (id: string) => void + } + devtools.unregisterCollection(id) +} + +/** + * Check if devtools are currently enabled (registry is present). + */ +export function isDevtoolsEnabled(): boolean { + if (typeof window === `undefined`) return false + return !!(window as any).__TANSTACK_DB_DEVTOOLS__ +} + +export function getDevtoolsRegistry(): DbDevtoolsRegistry | undefined { + if (typeof window === `undefined`) return undefined + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__! + + // Return the registry part of the devtools object + return { + collections: devtools.collections, + collectionsSignal: devtools.collectionsSignal, + transactionsSignal: devtools.transactionsSignal, + registerCollection: devtools.registerCollection, + unregisterCollection: devtools.unregisterCollection, + getCollection: devtools.getCollection, + releaseCollection: devtools.releaseCollection, + getAllCollectionMetadata: devtools.getAllCollectionMetadata, + getCollectionMetadata: devtools.getCollectionMetadata, + updateCollectionMetadata: devtools.updateCollectionMetadata, + updateTransactions: devtools.updateTransactions, + getTransactions: devtools.getTransactions, + getTransaction: devtools.getTransaction, + getTransactionDetails: devtools.getTransactionDetails, + clearTransactionHistory: devtools.clearTransactionHistory, + onTransactionStart: devtools.onTransactionStart, + onTransactionEnd: devtools.onTransactionEnd, + cleanup: devtools.cleanup, + garbageCollect: devtools.garbageCollect, + } as DbDevtoolsRegistry +} + +/** + * Trigger a metadata update for a collection in the devtools. + * This should be called by collections when their state changes significantly. + */ +export function triggerCollectionUpdate( + collection: CollectionImpl +): void { + if (typeof window === `undefined`) return + + const updateCallback = (collection as any).__devtoolsUpdateCallback + if (typeof updateCallback === `function`) { + updateCallback() + } +} + +/** + * Trigger a transaction update for a collection in the devtools. + * This should be called by collections when their transactions change. + */ +export function triggerTransactionUpdate( + collection: CollectionImpl +): void { + if (typeof window === `undefined`) return + + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + if (devtools?.updateTransactions) { + devtools.updateTransactions(collection.id) + } +} + +/** + * Clean up the devtools registry and all references. + * This is useful for testing or when you want to completely reset the devtools state. + */ +export function cleanupDevtools(): void { + if (typeof window === `undefined`) return + + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + if (devtools?.cleanup) { + devtools.cleanup() + delete (window as any).__TANSTACK_DB_DEVTOOLS__ + } +} diff --git a/packages/db-devtools/src/index.ts b/packages/db-devtools/src/index.ts new file mode 100644 index 000000000..af09fec3d --- /dev/null +++ b/packages/db-devtools/src/index.ts @@ -0,0 +1,15 @@ +// Core exports +export * from "./types" +export * from "./constants" +export * from "./devtools" +export * from "./registry" + +// Components +export { Explorer } from "./components/Explorer" + +// Main Devtools Class (follows TanStack pattern) +export { TanstackDbDevtools } from "./TanstackDbDevtools" +export type { TanstackDbDevtoolsConfig } from "./TanstackDbDevtools" + +// Export the initialization function +export { initializeDbDevtools } from "./devtools" diff --git a/packages/db-devtools/src/logo.tsx b/packages/db-devtools/src/logo.tsx new file mode 100644 index 000000000..09ca2fb5f --- /dev/null +++ b/packages/db-devtools/src/logo.tsx @@ -0,0 +1,817 @@ +import { createUniqueId } from "solid-js" + +export function TanStackLogo() { + const id = createUniqueId() + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts new file mode 100644 index 000000000..73d02509a --- /dev/null +++ b/packages/db-devtools/src/registry.ts @@ -0,0 +1,379 @@ +import { createSignal } from "solid-js" +import type { + CollectionMetadata, + CollectionRegistryEntry, + DbDevtoolsRegistry, + TransactionDetails, +} from "./types" + +class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { + public collections = new Map() + + // SolidJS signals for reactive updates + private _collectionsSignal = createSignal>([]) + private _transactionsSignal = createSignal>([]) + + constructor() { + // No polling needed; updates are now immediate via signals + } + + // Expose signals for reactive UI updates + public get collectionsSignal() { + return this._collectionsSignal[0] + } + + public get transactionsSignal() { + return this._transactionsSignal[0] + } + + private triggerUpdate = () => { + // Update collections signal + const collectionsData = this.getAllCollectionMetadata() + this._collectionsSignal[1](collectionsData) + + // Update transactions signal + const transactionsData = this.getTransactions() + this._transactionsSignal[1](transactionsData) + } + + private triggerCollectionUpdate = (id: string) => { + // Get the current collections array + const currentCollections = this._collectionsSignal[0]() + + // Find the index of the collection to update + const index = currentCollections.findIndex((c) => c.id === id) + + if (index !== -1) { + // Get updated metadata for this specific collection + const updatedMetadata = this.getCollectionMetadata(id) + if (updatedMetadata) { + // Create a new array with the updated collection + const newCollections = [...currentCollections] + newCollections[index] = updatedMetadata + this._collectionsSignal[1](newCollections) + } + } + } + + private triggerTransactionUpdate = (collectionId?: string) => { + // Get updated transactions data + const updatedTransactions = this.getTransactions(collectionId) + this._transactionsSignal[1](updatedTransactions) + } + + registerCollection = (collection: any): (() => void) | undefined => { + const metadata: CollectionMetadata = { + id: collection.id, + type: this.detectCollectionType(collection), + status: collection.status, + size: collection.size, + hasTransactions: collection.transactions.size > 0, + transactionCount: collection.transactions.size, + createdAt: new Date(), + lastUpdated: new Date(), + gcTime: collection.config.gcTime, + timings: this.isLiveQuery(collection) + ? { + totalIncrementalRuns: 0, + } + : undefined, + } + + // Create a callback that updates metadata for this specific collection + // This callback doesn't hold strong references to the collection + const updateCallback = () => { + this.updateCollectionMetadata(collection.id) + } + + // Create a callback that updates only transactions for this collection + const updateTransactionsCallback = () => { + this.triggerTransactionUpdate(collection.id) + } + + const entry: CollectionRegistryEntry = { + weakRef: new WeakRef(collection), + metadata, + isActive: false, + updateCallback, + updateTransactionsCallback, + } + + this.collections.set(collection.id, entry) + + // Track performance for live queries + if (this.isLiveQuery(collection)) { + this.instrumentLiveQuery(collection, entry) + } + + // Call the update callback immediately so devtools UI updates right away + updateCallback() + + // Trigger reactive update for immediate UI refresh + this.triggerUpdate() + + // Return the update callback for the collection to use + return updateCallback + } + + unregisterCollection = (id: string): void => { + const entry = this.collections.get(id) + if (entry) { + // Release any hard reference + entry.hardRef = undefined + entry.isActive = false + this.collections.delete(id) + } + + // Trigger reactive update for immediate UI refresh + this.triggerUpdate() + } + + getCollectionMetadata = (id: string): CollectionMetadata | undefined => { + const entry = this.collections.get(id) + if (!entry) return undefined + + // Try to get fresh data from the collection if it's still alive + const collection = entry.weakRef.deref() + if (collection) { + // Update metadata with fresh data + entry.metadata.status = collection.status + entry.metadata.size = collection.size + entry.metadata.hasTransactions = collection.transactions.size > 0 + entry.metadata.transactionCount = collection.transactions.size + entry.metadata.lastUpdated = new Date() + } + + return { ...entry.metadata } + } + + getAllCollectionMetadata = (): Array => { + const results: Array = [] + + for (const [_id, entry] of this.collections) { + const collection = entry.weakRef.deref() + if (collection) { + // Collection is still alive, update metadata + entry.metadata.status = collection.status + entry.metadata.size = collection.size + entry.metadata.hasTransactions = collection.transactions.size > 0 + entry.metadata.transactionCount = collection.transactions.size + entry.metadata.lastUpdated = new Date() + results.push({ ...entry.metadata }) + } else { + // Collection was garbage collected, mark it + entry.metadata.status = `cleaned-up` + entry.metadata.lastUpdated = new Date() + results.push({ ...entry.metadata }) + } + } + + return results + } + + updateCollectionMetadata = (id: string): void => { + const entry = this.collections.get(id) + if (!entry) return + + const collection = entry.weakRef.deref() + if (collection) { + // Update metadata with fresh data from the collection + entry.metadata.status = collection.status + entry.metadata.size = collection.size + entry.metadata.hasTransactions = collection.transactions.size > 0 + entry.metadata.transactionCount = collection.transactions.size + entry.metadata.lastUpdated = new Date() + } + + // Use efficient update that only changes the specific collection + this.triggerCollectionUpdate(id) + + // Also update transactions since they may have changed + this.triggerTransactionUpdate(id) + } + + updateTransactions = (collectionId?: string): void => { + this.triggerTransactionUpdate(collectionId) + } + + getCollection = (id: string): any => { + const entry = this.collections.get(id) + if (!entry) return undefined + + const collection = entry.weakRef.deref() + if (collection && !entry.isActive) { + // Create hard reference + entry.hardRef = collection + entry.isActive = true + } + + return collection + } + + releaseCollection = (id: string): void => { + const entry = this.collections.get(id) + if (entry && entry.isActive) { + // Release hard reference + entry.hardRef = undefined + entry.isActive = false + } + } + + getTransactions = (collectionId?: string): Array => { + const transactions: Array = [] + + for (const [_id, entry] of this.collections) { + if (collectionId && _id !== collectionId) continue + + const collection = entry.weakRef.deref() + if (!collection) continue + + for (const [txId, transaction] of collection.transactions) { + transactions.push({ + id: txId, + collectionId: _id, + state: transaction.state, + mutations: transaction.mutations.map((m: any) => ({ + id: m.mutationId, + type: m.type, + key: m.key, + optimistic: m.optimistic, + createdAt: m.createdAt, + original: m.original, + modified: m.modified, + changes: m.changes, + })), + createdAt: transaction.createdAt, + updatedAt: transaction.createdAt, // Transaction doesn't have updatedAt, using createdAt + isPersisted: transaction.state === `completed`, + }) + } + } + + return transactions.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + ) + } + + getTransaction = (id: string): TransactionDetails | undefined => { + for (const [_collectionId, entry] of this.collections) { + const collection = entry.weakRef.deref() + if (!collection) continue + + const transaction = collection.transactions.get(id) + if (transaction) { + return { + id, + collectionId: _collectionId, + state: transaction.state, + mutations: transaction.mutations.map((m: any) => ({ + id: m.mutationId, + type: m.type, + key: m.key, + optimistic: m.optimistic, + createdAt: m.createdAt, + original: m.original, + modified: m.modified, + changes: m.changes, + })), + createdAt: transaction.createdAt, + updatedAt: transaction.createdAt, // Transaction doesn't have updatedAt, using createdAt + isPersisted: transaction.state === `completed`, + } + } + } + return undefined + } + + cleanup = (): void => { + // Stop polling + // No polling to stop + + // Release all hard references + for (const [_id, entry] of this.collections) { + if (entry.isActive) { + entry.hardRef = undefined + entry.isActive = false + } + } + } + + garbageCollect = (): void => { + // Remove entries for collections that have been garbage collected + for (const [id, entry] of this.collections) { + const collection = entry.weakRef.deref() + if (!collection) { + this.collections.delete(id) + } + } + } + + private detectCollectionType = (collection: any): string => { + // Check the new collection type marker first + if (collection.config.collectionType) { + return collection.config.collectionType + } + + // Default to generic collection + return `generic` + } + + private isLiveQuery = (collection: any): boolean => { + return this.detectCollectionType(collection) === `live-query` + } + + private instrumentLiveQuery = ( + collection: any, + entry: CollectionRegistryEntry + ): void => { + // This is where we would add performance tracking for live queries + // We'll need to hook into the query execution pipeline to track timings + // For now, this is a placeholder + if (!entry.metadata.timings) { + entry.metadata.timings = { + totalIncrementalRuns: 0, + } + } + } +} + +// Create and export the global registry +export function createDbDevtoolsRegistry(): DbDevtoolsRegistry { + return new DbDevtoolsRegistryImpl() +} + +// Initialize the global registry if not already present +export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { + // SSR safety check + if (typeof window === `undefined`) { + // Return a no-op registry for server-side rendering + const [collectionsSignal] = createSignal>([]) + const [transactionsSignal] = createSignal>([]) + + return { + collections: new Map(), + collectionsSignal, + transactionsSignal, + registerCollection: () => undefined, + unregisterCollection: () => {}, + getCollection: () => undefined, + releaseCollection: () => {}, + getAllCollectionMetadata: () => [], + getCollectionMetadata: () => undefined, + updateCollectionMetadata: () => {}, + updateTransactions: () => {}, + getTransactions: () => [], + getTransaction: () => undefined, + getTransactionDetails: () => undefined, + clearTransactionHistory: () => {}, + onTransactionStart: () => {}, + onTransactionEnd: () => {}, + cleanup: () => {}, + garbageCollect: () => {}, + } as DbDevtoolsRegistry + } + + if (!(window as any).__TANSTACK_DB_DEVTOOLS__) { + ;(window as any).__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() + } + return (window as any).__TANSTACK_DB_DEVTOOLS__ as DbDevtoolsRegistry +} diff --git a/packages/db-devtools/src/tokens.ts b/packages/db-devtools/src/tokens.ts new file mode 100644 index 000000000..655bd7936 --- /dev/null +++ b/packages/db-devtools/src/tokens.ts @@ -0,0 +1,264 @@ +export const tokens = { + colors: { + inherit: `inherit`, + current: `currentColor`, + transparent: `transparent`, + black: `#000000`, + white: `#ffffff`, + neutral: { + 50: `#f9fafb`, + 100: `#f2f4f7`, + 200: `#eaecf0`, + 300: `#d0d5dd`, + 400: `#98a2b3`, + 500: `#667085`, + 600: `#475467`, + 700: `#344054`, + 800: `#1d2939`, + 900: `#101828`, + }, + darkGray: { + 50: `#525c7a`, + 100: `#49536e`, + 200: `#414962`, + 300: `#394056`, + 400: `#313749`, + 500: `#292e3d`, + 600: `#212530`, + 700: `#191c24`, + 800: `#111318`, + 900: `#0b0d10`, + }, + gray: { + 50: `#f9fafb`, + 100: `#f2f4f7`, + 200: `#eaecf0`, + 300: `#d0d5dd`, + 400: `#98a2b3`, + 500: `#667085`, + 600: `#475467`, + 700: `#344054`, + 800: `#1d2939`, + 900: `#101828`, + }, + blue: { + 25: `#F5FAFF`, + 50: `#EFF8FF`, + 100: `#D1E9FF`, + 200: `#B2DDFF`, + 300: `#84CAFF`, + 400: `#53B1FD`, + 500: `#2E90FA`, + 600: `#1570EF`, + 700: `#175CD3`, + 800: `#1849A9`, + 900: `#194185`, + }, + green: { + 25: `#F6FEF9`, + 50: `#ECFDF3`, + 100: `#D1FADF`, + 200: `#A6F4C5`, + 300: `#6CE9A6`, + 400: `#32D583`, + 500: `#12B76A`, + 600: `#039855`, + 700: `#027A48`, + 800: `#05603A`, + 900: `#054F31`, + }, + red: { + 50: `#fef2f2`, + 100: `#fee2e2`, + 200: `#fecaca`, + 300: `#fca5a5`, + 400: `#f87171`, + 500: `#ef4444`, + 600: `#dc2626`, + 700: `#b91c1c`, + 800: `#991b1b`, + 900: `#7f1d1d`, + 950: `#450a0a`, + }, + yellow: { + 25: `#FFFCF5`, + 50: `#FFFAEB`, + 100: `#FEF0C7`, + 200: `#FEDF89`, + 300: `#FEC84B`, + 400: `#FDB022`, + 500: `#F79009`, + 600: `#DC6803`, + 700: `#B54708`, + 800: `#93370D`, + 900: `#7A2E0E`, + }, + purple: { + 25: `#FAFAFF`, + 50: `#F4F3FF`, + 100: `#EBE9FE`, + 200: `#D9D6FE`, + 300: `#BDB4FE`, + 400: `#9B8AFB`, + 500: `#7A5AF8`, + 600: `#6328EF`, + 700: `#5912D3`, + 800: `#4A0FB0`, + 900: `#3E0C8E`, + }, + orange: { + 25: `#FFFAF5`, + 50: `#FFF4ED`, + 100: `#FFE6D5`, + 200: `#FFD6AE`, + 300: `#FF9C66`, + 400: `#FF692E`, + 500: `#FF4405`, + 600: `#E62E05`, + 700: `#BC1B06`, + 800: `#97180C`, + 900: `#771A0D`, + }, + }, + size: { + px: `1px`, + 0: `0px`, + 0.5: `0.125rem`, + 1: `0.25rem`, + 1.5: `0.375rem`, + 2: `0.5rem`, + 2.5: `0.625rem`, + 3: `0.75rem`, + 3.5: `0.875rem`, + 4: `1rem`, + 5: `1.25rem`, + 6: `1.5rem`, + 7: `1.75rem`, + 8: `2rem`, + 9: `2.25rem`, + 10: `2.5rem`, + 11: `2.75rem`, + 12: `3rem`, + 14: `3.5rem`, + 16: `4rem`, + 20: `5rem`, + 24: `6rem`, + 28: `7rem`, + 32: `8rem`, + 36: `9rem`, + 40: `10rem`, + 44: `11rem`, + 48: `12rem`, + 52: `13rem`, + 56: `14rem`, + 60: `15rem`, + 64: `16rem`, + 72: `18rem`, + 80: `20rem`, + 96: `24rem`, + }, + alpha: { + 5: `0D`, + 10: `1A`, + 20: `33`, + 30: `4D`, + 40: `66`, + 50: `80`, + 60: `99`, + 70: `B3`, + 80: `CC`, + 90: `E6`, + 95: `F2`, + }, + font: { + size: { + xs: `0.75rem`, + sm: `0.875rem`, + md: `1rem`, + lg: `1.125rem`, + xl: `1.25rem`, + "2xl": `1.5rem`, + "3xl": `1.875rem`, + "4xl": `2.25rem`, + }, + lineHeight: { + xs: `1rem`, + sm: `1.25rem`, + md: `1.5rem`, + lg: `1.75rem`, + xl: `1.75rem`, + "2xl": `2rem`, + "3xl": `2.25rem`, + "4xl": `2.5rem`, + }, + weight: { + thin: `100`, + extralight: `200`, + light: `300`, + normal: `400`, + medium: `500`, + semibold: `600`, + bold: `700`, + extrabold: `800`, + black: `900`, + }, + fontFamily: { + sans: [ + `ui-sans-serif`, + `system-ui`, + `-apple-system`, + `BlinkMacSystemFont`, + `"Segoe UI"`, + `Roboto`, + `"Helvetica Neue"`, + `Arial`, + `"Noto Sans"`, + `sans-serif`, + `"Apple Color Emoji"`, + `"Segoe UI Emoji"`, + `"Segoe UI Symbol"`, + `"Noto Color Emoji"`, + ].join(`, `), + serif: [ + `ui-serif`, + `Georgia`, + `Cambria`, + `"Times New Roman"`, + `Times`, + `serif`, + ].join(`, `), + mono: [ + `ui-monospace`, + `SFMono-Regular`, + `"Menlo"`, + `Monaco`, + `Consolas`, + `"Liberation Mono"`, + `"Courier New"`, + `monospace`, + ].join(`, `), + }, + }, + shadow: { + xs: `0 1px 2px 0 rgb(0 0 0 / 0.05)`, + sm: `0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)`, + md: `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)`, + lg: `0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)`, + xl: `0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)`, + "2xl": `0 25px 50px -12px rgb(0 0 0 / 0.25)`, + inner: `inset 0 2px 4px 0 rgb(0 0 0 / 0.05)`, + }, + border: { + radius: { + none: `0px`, + sm: `0.125rem`, + md: `0.375rem`, + lg: `0.5rem`, + xl: `0.75rem`, + "2xl": `1rem`, + "3xl": `1.5rem`, + full: `9999px`, + xs: `0.0625rem`, + }, + }, +} diff --git a/packages/db-devtools/src/types.ts b/packages/db-devtools/src/types.ts new file mode 100644 index 000000000..34eaeaf99 --- /dev/null +++ b/packages/db-devtools/src/types.ts @@ -0,0 +1,134 @@ +import type { CollectionImpl } from "../../db/src/collection" +import type { CollectionStatus } from "../../db/src/types" + +export interface DbDevtoolsConfig { + /** + * Set this true if you want the dev tools to default to being open + */ + initialIsOpen?: boolean + /** + * The position of the TanStack logo to open and close the devtools panel. + * 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'relative' + * Defaults to 'bottom-right' + */ + position?: + | `top-left` + | `top-right` + | `bottom-left` + | `bottom-right` + | `relative` + /** + * Use this to add props to the panel. For example, you can add className, style (merge and override default style), etc. + */ + panelProps?: Record + /** + * Use this to add props to the close button. For example, you can add className, style (merge and override default style), etc. + */ + closeButtonProps?: Record + /** + * Use this to add props to the toggle button. For example, you can add className, style (merge and override default style), etc. + */ + toggleButtonProps?: Record + /** + * The prefix for the localStorage keys used to store the open state and position of the devtools panel. + * Defaults to 'tanstackDbDevtools' + */ + storageKey?: string + /** + * A boolean variable indicating whether the pannel is open or closed. + * If defined, the open state will be controlled by this variable, otherwise it will be controlled by the component's internal state. + */ + panelState?: `open` | `closed` + /** + * A callback function which will be called when the open state changes. + * If panelState is defined, this callback will be called when the user toggles the panel. + */ + onPanelStateChange?: (isOpen: boolean) => void +} + +export interface CollectionMetadata { + id: string + type: string + status: CollectionStatus + size: number + hasTransactions: boolean + transactionCount: number + createdAt: Date + lastUpdated: Date + gcTime?: number + + // Performance tracking for live queries + timings?: { + initialRunTime?: number + lastIncrementalRunTime?: number + totalIncrementalRuns: number + averageIncrementalRunTime?: number + } + + // Additional metadata + schema?: any + syncConfig?: any +} + +export interface CollectionRegistryEntry { + weakRef: WeakRef> + metadata: CollectionMetadata + isActive: boolean // Whether we're currently viewing this collection (hard ref held) + hardRef?: CollectionImpl // Only set when actively viewing + updateCallback?: () => void // Callback to trigger metadata update (doesn't hold strong refs) + updateTransactionsCallback?: () => void // Callback to trigger transaction update only (doesn't hold strong refs) +} + +export interface TransactionDetails { + id: string + collectionId: string + state: string + mutations: Array<{ + id: string + type: `insert` | `update` | `delete` + key: any + optimistic: boolean + createdAt: Date + original?: any + modified?: any + changes?: any + }> + createdAt: Date + updatedAt: Date + isPersisted: boolean +} + +export interface DbDevtoolsRegistry { + collections: Map + + // SolidJS signals for reactive UI updates + collectionsSignal: () => Array + transactionsSignal: () => Array + + // Registration methods + registerCollection: ( + collection: CollectionImpl + ) => (() => void) | undefined + unregisterCollection: (id: string) => void + + // Metadata access + getCollectionMetadata: (id: string) => CollectionMetadata | undefined + getAllCollectionMetadata: () => Array + updateCollectionMetadata: (id: string) => void // Trigger immediate metadata update + updateTransactions: (collectionId?: string) => void // Trigger immediate transaction update + + // Collection access (creates hard refs) + getCollection: (id: string) => CollectionImpl | undefined + releaseCollection: (id: string) => void + + // Transaction access + getTransactions: (collectionId?: string) => Array + getTransaction: (id: string) => TransactionDetails | undefined + + // Cleanup utilities + cleanup: () => void + garbageCollect: () => void +} + +// Window global interface is already declared in @tanstack/db +// The DbDevtoolsRegistry interface extends the base interface used there diff --git a/packages/db-devtools/src/useLocalStorage.ts b/packages/db-devtools/src/useLocalStorage.ts new file mode 100644 index 000000000..6359c36b7 --- /dev/null +++ b/packages/db-devtools/src/useLocalStorage.ts @@ -0,0 +1,41 @@ +import { createEffect, createSignal } from "solid-js" +import type { Accessor, Setter } from "solid-js" + +export function useLocalStorage( + key: string, + defaultValue?: T +): [Accessor, Setter] { + // Initialize with default value or try to get from localStorage + const getInitialValue = (): T => { + if (typeof window === `undefined`) { + return defaultValue as T + } + + try { + const item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : (defaultValue as T) + } catch { + return defaultValue as T + } + } + + const [value, setValue] = createSignal(getInitialValue()) + + // Update localStorage when value changes + createEffect(() => { + if (typeof window === `undefined`) return + + try { + const currentValue = value() + if (currentValue === undefined) { + window.localStorage.removeItem(key) + } else { + window.localStorage.setItem(key, JSON.stringify(currentValue)) + } + } catch {} + }) + + return [value, setValue] +} + +export default useLocalStorage diff --git a/packages/db-devtools/src/useStyles.tsx b/packages/db-devtools/src/useStyles.tsx new file mode 100644 index 000000000..1746672ed --- /dev/null +++ b/packages/db-devtools/src/useStyles.tsx @@ -0,0 +1,569 @@ +import * as goober from "goober" +import { createSignal, useContext } from "solid-js" +import { tokens } from "./tokens" +import { ShadowDomTargetContext } from "./contexts" +import type { Accessor } from "solid-js" + +const stylesFactory = (shadowDOMTarget?: ShadowRoot) => { + const { colors, font, size, alpha, border } = tokens + const { fontFamily, size: fontSize } = font + const css = shadowDOMTarget + ? goober.css.bind({ target: shadowDOMTarget }) + : goober.css + + return { + devtoolsPanelContainer: css` + direction: ltr; + position: fixed; + bottom: 0; + right: 0; + z-index: 99999; + width: 100%; + max-height: 90%; + border-top: 1px solid ${colors.gray[700]}; + transform-origin: top; + `, + devtoolsPanelContainerVisibility: (isOpen: boolean) => { + return css` + visibility: ${isOpen ? `visible` : `hidden`}; + ` + }, + devtoolsPanelContainerResizing: (isResizing: Accessor) => { + if (isResizing()) { + return css` + transition: none; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + ` + } + + return css` + transition: all 0.4s ease; + ` + }, + devtoolsPanelContainerAnimation: (isOpen: boolean, height: number) => { + if (isOpen) { + return css` + pointer-events: auto; + transform: translateY(0); + ` + } + return css` + pointer-events: none; + transform: translateY(${height}px); + ` + }, + logo: css` + cursor: pointer; + display: flex; + flex-direction: column; + background-color: transparent; + border: none; + font-family: ${fontFamily.sans}; + gap: ${tokens.size[0.5]}; + padding: 0px; + &:hover { + opacity: 0.7; + } + &:focus-visible { + outline-offset: 4px; + border-radius: ${border.radius.xs}; + outline: 2px solid ${colors.blue[800]}; + } + `, + tanstackLogo: css` + font-size: ${font.size.md}; + font-weight: ${font.weight.bold}; + line-height: ${font.lineHeight.xs}; + white-space: nowrap; + color: ${colors.gray[300]}; + `, + dbLogo: css` + font-weight: ${font.weight.semibold}; + font-size: ${font.size.xs}; + background: linear-gradient( + to right, + rgb(249, 115, 22), + rgb(194, 65, 12) + ); + background-clip: text; + -webkit-background-clip: text; + line-height: 1; + -webkit-text-fill-color: transparent; + white-space: nowrap; + `, + devtoolsPanel: css` + display: flex; + font-size: ${fontSize.sm}; + font-family: ${fontFamily.sans}; + background-color: ${colors.darkGray[700]}; + color: ${colors.gray[300]}; + + @media (max-width: 700px) { + flex-direction: column; + } + @media (max-width: 600px) { + font-size: ${fontSize.xs}; + } + `, + dragHandle: css` + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 4px; + cursor: row-resize; + z-index: 100000; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + &:hover { + background-color: ${colors.purple[400]}${alpha[90]}; + } + `, + firstContainer: css` + flex: 0 0 35%; + min-height: 40%; + max-height: 100%; + overflow: auto; + border-right: 1px solid ${colors.gray[700]}; + display: flex; + flex-direction: column; + `, + secondContainer: css` + flex: 1 1 500px; + min-height: 40%; + max-height: 100%; + overflow: auto; + display: flex; + flex-direction: column; + `, + collectionsList: css` + overflow-y: auto; + flex: 1; + `, + collectionsHeader: css` + display: flex; + align-items: center; + padding: ${size[2]} ${size[2.5]}; + gap: ${size[2.5]}; + border-bottom: ${colors.darkGray[500]} 1px solid; + align-items: center; + `, + mainCloseBtn: css` + background: ${colors.darkGray[700]}; + padding: ${size[1]} ${size[2]} ${size[1]} ${size[1.5]}; + border-radius: ${border.radius.md}; + position: fixed; + z-index: 99999; + display: inline-flex; + width: fit-content; + cursor: pointer; + appearance: none; + border: 0; + gap: 8px; + align-items: center; + border: 1px solid ${colors.gray[500]}; + font-size: ${font.size.xs}; + cursor: pointer; + transition: all 0.25s ease-out; + + &:hover { + background: ${colors.darkGray[500]}; + } + `, + mainCloseBtnPosition: ( + position: `top-left` | `top-right` | `bottom-left` | `bottom-right` + ) => { + const base = css` + ${position === `top-left` ? `top: ${size[2]}; left: ${size[2]};` : ``} + ${position === `top-right` ? `top: ${size[2]}; right: ${size[2]};` : ``} + ${position === `bottom-left` + ? `bottom: ${size[2]}; left: ${size[2]};` + : ``} + ${position === `bottom-right` + ? `bottom: ${size[2]}; right: ${size[2]};` + : ``} + ` + return base + }, + mainCloseBtnAnimation: (isOpen: boolean) => { + if (!isOpen) { + return css` + opacity: 1; + pointer-events: auto; + visibility: visible; + ` + } + return css` + opacity: 0; + pointer-events: none; + visibility: hidden; + ` + }, + dbLogoCloseButton: css` + font-weight: ${font.weight.semibold}; + font-size: ${font.size.xs}; + background: linear-gradient( + to right, + rgb(249, 115, 22), + rgb(194, 65, 12) + ); + background-clip: text; + -webkit-background-clip: text; + line-height: 1; + -webkit-text-fill-color: transparent; + white-space: nowrap; + `, + mainCloseBtnDivider: css` + width: 1px; + background: ${tokens.colors.gray[600]}; + height: 100%; + border-radius: 999999px; + color: transparent; + `, + mainCloseBtnIconContainer: css` + position: relative; + width: ${size[5]}; + height: ${size[5]}; + background: linear-gradient(45deg, #06b6d4, #3b82f6); + border-radius: 999999px; + overflow: hidden; + `, + mainCloseBtnIconOuter: css` + width: ${size[5]}; + height: ${size[5]}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + filter: blur(3px) saturate(1.8) contrast(2); + `, + mainCloseBtnIconInner: css` + width: ${size[4]}; + height: ${size[4]}; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + `, + panelCloseBtn: css` + position: absolute; + cursor: pointer; + z-index: 100001; + display: flex; + align-items: center; + justify-content: center; + outline: none; + background-color: ${colors.darkGray[700]}; + &:hover { + background-color: ${colors.darkGray[500]}; + } + + top: 0; + right: ${size[2]}; + transform: translate(0, -100%); + border-right: ${colors.darkGray[300]} 1px solid; + border-left: ${colors.darkGray[300]} 1px solid; + border-top: ${colors.darkGray[300]} 1px solid; + border-bottom: none; + border-radius: ${border.radius.sm} ${border.radius.sm} 0px 0px; + padding: ${size[1]} ${size[1.5]} ${size[0.5]} ${size[1.5]}; + + &::after { + content: " "; + position: absolute; + top: 100%; + left: -${size[2.5]}; + height: ${size[1.5]}; + width: calc(100% + ${size[5]}); + } + `, + panelCloseBtnIcon: css` + color: ${colors.gray[400]}; + width: ${size[2]}; + height: ${size[2]}; + `, + collectionItem: css` + display: flex; + align-items: center; + padding: ${size[2]}; + border-bottom: 1px solid ${colors.gray[700]}; + cursor: pointer; + background-color: ${colors.darkGray[700]}; + transition: all 0.2s ease; + + &:hover { + background-color: ${colors.darkGray[600]}; + } + `, + collectionItemActive: css` + background-color: ${colors.darkGray[600]}; + border-left: 3px solid ${colors.blue[500]}; + `, + collectionName: css` + font-weight: ${font.weight.medium}; + color: ${colors.gray[200]}; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `, + collectionStatus: css` + font-size: ${fontSize.xs}; + padding: ${size[0.5]} ${size[1]}; + border-radius: ${border.radius.sm}; + font-weight: ${font.weight.medium}; + background-color: ${colors.green[900]}; + color: ${colors.green[300]}; + border: 1px solid ${colors.green[700]}; + `, + collectionStatusError: css` + background-color: ${colors.red[900]}; + color: ${colors.red[300]}; + border: 1px solid ${colors.red[700]}; + `, + collectionCount: css` + font-size: ${fontSize.xs}; + color: ${colors.gray[400]}; + margin-left: ${size[2]}; + `, + collectionStats: css` + display: flex; + gap: ${size[1]}; + font-size: ${fontSize.xs}; + color: ${colors.gray[400]}; + font-variant-numeric: tabular-nums; + line-height: ${font.lineHeight.xs}; + align-items: center; + `, + detailsPanel: css` + display: flex; + flex-direction: column; + background-color: ${colors.darkGray[700]}; + color: ${colors.gray[300]}; + width: 100%; + height: 100%; + overflow-y: auto; + `, + detailsHeader: css` + display: flex; + align-items: center; + font-weight: ${font.weight.semibold}; + color: ${colors.gray[200]}; + font-size: ${fontSize.sm}; + `, + transactionHeader: css` + display: flex; + align-items: center; + padding: ${size[1.5]} ${size[2]}; + background-color: ${colors.darkGray[600]}; + border-bottom: 1px solid ${colors.gray[700]}; + font-weight: ${font.weight.semibold}; + color: ${colors.gray[200]}; + font-size: ${fontSize.sm}; + `, + transactionSubHeader: css` + display: flex; + align-items: center; + padding: ${size[1.5]} ${size[2]}; + background-color: ${colors.darkGray[600]}; + border-bottom: 1px solid ${colors.gray[700]}; + font-weight: ${font.weight.semibold}; + color: ${colors.gray[200]}; + font-size: ${fontSize.xs}; + `, + detailsHeaderRow: css` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${size[2]}; + background-color: ${colors.darkGray[600]}; + border-bottom: 1px solid ${colors.gray[700]}; + font-weight: ${font.weight.semibold}; + color: ${colors.gray[200]}; + font-size: ${fontSize.sm}; + width: 100%; + `, + detailsContent: css` + flex: 1; + padding: ${size[2]}; + overflow-y: auto; + `, + detailsContentNoPadding: css` + flex: 1; + overflow-y: auto; + `, + explorerContainer: css` + font-family: ${fontFamily.mono}; + font-size: ${fontSize.xs}; + color: ${colors.gray[300]}; + overflow-y: auto; + `, + row: css` + display: flex; + align-items: center; + padding: ${size[2]} ${size[2.5]}; + gap: ${size[2.5]}; + border-bottom: ${colors.gray[700]} 1px solid; + align-items: center; + `, + headerContainer: css` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: ${size[0.5]} ${size[2]}; + `, + collectionsExplorerContainer: css` + overflow-y: auto; + flex: 1; + `, + collectionsExplorer: css` + /* Removed padding to use full width and height */ + `, + collectionGroup: css` + /* Removed margin to eliminate extra spacing */ + `, + collectionGroupHeader: css` + padding: ${size[1.5]} ${size[2]}; + font-size: ${fontSize.xs}; + font-weight: ${font.weight.semibold}; + color: ${colors.gray[400]}; + background: ${colors.darkGray[600]}; + border-bottom: 1px solid ${colors.gray[700]}; + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + justify-content: space-between; + align-items: center; + `, + collectionGroupStats: css` + display: flex; + gap: ${size[1]}; + font-size: ${fontSize.xs}; + color: ${colors.gray[500]}; + font-variant-numeric: tabular-nums; + line-height: ${font.lineHeight.xs}; + align-items: center; + font-weight: ${font.weight.normal}; + text-transform: none; + letter-spacing: normal; + `, + tabNav: css` + display: flex; + gap: ${size[1]}; + `, + tabBtn: css` + padding: ${size[1]} ${size[2]}; + background: transparent; + border: 1px solid ${colors.gray[600]}; + border-radius: ${border.radius.sm}; + color: ${colors.gray[400]}; + cursor: pointer; + font-size: ${fontSize.xs}; + font-weight: ${font.weight.medium}; + + &:hover { + background: ${colors.darkGray[500]}; + border-color: ${colors.gray[500]}; + } + `, + tabBtnActive: css` + background: ${colors.blue[500]}; + color: ${colors.white}; + border-color: ${colors.blue[400]}; + + &:hover { + background: ${colors.blue[600]}; + border-color: ${colors.blue[500]}; + } + `, + sidebarContent: css` + flex: 1; + overflow-y: auto; + `, + transactionsExplorer: css` + display: flex; + flex-direction: column; + flex: 1; + `, + noDataMessage: css` + padding: ${size[4]}; + text-align: center; + color: ${colors.gray[500]}; + font-style: italic; + `, + collectionTabNav: css` + display: flex; + gap: ${size[1]}; + `, + collectionTabBtn: css` + padding: ${size[1]} ${size[2]}; + background: transparent; + border: 1px solid ${colors.gray[600]}; + border-radius: ${border.radius.sm}; + color: ${colors.gray[400]}; + cursor: pointer; + font-size: ${fontSize.xs}; + font-weight: ${font.weight.medium}; + position: relative; + + &:hover { + background: ${colors.darkGray[500]}; + border-color: ${colors.gray[500]}; + } + `, + collectionTabBtnActive: css` + background: ${colors.blue[500]} !important; + color: ${colors.white} !important; + border-color: ${colors.blue[400]} !important; + + &:hover { + background: ${colors.blue[600]} !important; + border-color: ${colors.blue[500]} !important; + } + `, + tabBadge: css` + position: absolute; + top: -${size[0.5]}; + right: -${size[0.5]}; + background: ${colors.red[500]}; + color: ${colors.white}; + border-radius: 50%; + width: ${size[3]}; + height: ${size[3]}; + display: flex; + align-items: center; + justify-content: center; + font-size: ${fontSize.xs}; + font-weight: ${font.weight.bold}; + line-height: 1; + `, + splitPanelContainer: css` + display: flex; + width: 100%; + height: 100%; + `, + splitPanelLeft: css` + flex: 0 0 50%; + height: 100%; + border-right: 1px solid ${colors.gray[700]}; + overflow-y: auto; + `, + splitPanelRight: css` + flex: 1; + height: 100%; + overflow-y: auto; + `, + } +} + +export function useStyles() { + const shadowDomTarget = useContext(ShadowDomTargetContext) + const [_styles] = createSignal(stylesFactory(shadowDomTarget)) + return _styles +} diff --git a/packages/db-devtools/src/utils.tsx b/packages/db-devtools/src/utils.tsx new file mode 100644 index 000000000..a2116a068 --- /dev/null +++ b/packages/db-devtools/src/utils.tsx @@ -0,0 +1,102 @@ +import { createSignal, onMount } from "solid-js" +import type { Accessor } from "solid-js" + +export function useIsMounted(): Accessor { + const [isMounted, setIsMounted] = createSignal(false) + + onMount(() => { + setIsMounted(true) + }) + + return isMounted +} + +export function multiSortBy( + items: Array, + sorters: Array<(item: T) => any> +): Array { + return [...items].sort((a, b) => { + for (const sorter of sorters) { + const aVal = sorter(a) + const bVal = sorter(b) + if (aVal < bVal) return -1 + if (aVal > bVal) return 1 + } + return 0 + }) +} + +export function getStatusColor(status: string): string { + switch (status) { + case `active`: + case `success`: + return `green` + case `error`: + case `failed`: + return `red` + case `pending`: + case `loading`: + return `yellow` + case `idle`: + default: + return `gray` + } +} + +export function displayValue(value: any, space?: number): string { + if (typeof value === `string`) { + return JSON.stringify(value) + } + + if (typeof value === `number` || typeof value === `boolean`) { + return String(value) + } + + if (value === null) { + return `null` + } + + if (value === undefined) { + return `undefined` + } + + if (typeof value === `object`) { + return JSON.stringify(value, null, space) + } + + return String(value) +} + +export function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp) + return date.toLocaleTimeString() +} + +export function truncate(str: string, length: number): string { + if (str.length <= length) return str + return str.slice(0, length) + `...` +} + +export function isObject(value: any): value is object { + return value !== null && typeof value === `object` +} + +export function isArray(value: any): value is Array { + return Array.isArray(value) +} + +export function getKeys(obj: any): Array { + if (!isObject(obj)) return [] + return Object.keys(obj) +} + +export function sortBy(items: Array, sorter: (item: T) => any): Array { + return [...items].sort((a, b) => { + const aVal = sorter(a) + const bVal = sorter(b) + + if (aVal < bVal) return -1 + if (aVal > bVal) return 1 + return 0 + }) +} diff --git a/packages/db-devtools/src/utils/formatTime.ts b/packages/db-devtools/src/utils/formatTime.ts new file mode 100644 index 000000000..0e591e07d --- /dev/null +++ b/packages/db-devtools/src/utils/formatTime.ts @@ -0,0 +1,20 @@ +export function formatTime(ms: number): string { + if (ms === 0) return `0s` + + const units = [`s`, `min`, `h`, `d`] + const values = [ms / 1000, ms / 60000, ms / 3600000, ms / 86400000] + + let chosenUnitIndex = 0 + for (let i = 1; i < values.length; i++) { + if (values[i]! < 1) break + chosenUnitIndex = i + } + + const formatter = new Intl.NumberFormat(navigator.language, { + compactDisplay: `short`, + notation: `compact`, + maximumFractionDigits: 0, + }) + + return formatter.format(values[chosenUnitIndex]!) + units[chosenUnitIndex] +} diff --git a/packages/db-devtools/src/utils/index.ts b/packages/db-devtools/src/utils/index.ts new file mode 100644 index 000000000..1e7072760 --- /dev/null +++ b/packages/db-devtools/src/utils/index.ts @@ -0,0 +1 @@ +export { formatTime } from "./formatTime" diff --git a/packages/db-devtools/tsconfig.json b/packages/db-devtools/tsconfig.json new file mode 100644 index 000000000..c40804a13 --- /dev/null +++ b/packages/db-devtools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "build", + "declaration": true, + "declarationMap": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*", "*.config.ts"], + "exclude": ["build", "dist", "node_modules"] +} \ No newline at end of file diff --git a/packages/db-devtools/tsconfig.prod.json b/packages/db-devtools/tsconfig.prod.json new file mode 100644 index 000000000..ba6b831b4 --- /dev/null +++ b/packages/db-devtools/tsconfig.prod.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "target": "ES2021", + "lib": ["ES2021", "DOM"] + }, + "exclude": ["src/__tests__", "**/*.test.ts", "**/*.test.tsx"] +} \ No newline at end of file diff --git a/packages/db-devtools/tsup.config.ts b/packages/db-devtools/tsup.config.ts new file mode 100644 index 000000000..ab62ae712 --- /dev/null +++ b/packages/db-devtools/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: [`src/index.ts`], + format: [`esm`, `cjs`], + dts: true, + sourcemap: true, + clean: true, + outDir: `build`, + external: [`solid-js`, `solid-js/web`, `@tanstack/db`], + esbuildOptions(options) { + // Use SolidJS-compatible JSX settings + options.jsx = `automatic` + options.jsxImportSource = `solid-js` + // Add SolidJS runtime helpers + options.banner = { + js: `import{template as _$template,delegateEvents as _$delegateEvents,addEventListener as _$addEventListener,classList as _$classList,style as _$style,setAttribute as _$setAttribute,setProperty as _$setProperty,className as _$className,textContent as _$textContent,innerHTML as _$innerHTML}from"solid-js/web";`, + } + }, +}) diff --git a/packages/db-devtools/vite.config.ts b/packages/db-devtools/vite.config.ts new file mode 100644 index 000000000..7ff1ef8c4 --- /dev/null +++ b/packages/db-devtools/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" +import dts from "vite-plugin-dts" + +export default defineConfig({ + plugins: [ + solid(), + dts({ + insertTypesEntry: true, + include: [`src/**/*`], + exclude: [`src/**/*.test.*`, `src/__tests__/**/*`], + }), + ], + build: { + target: `esnext`, + lib: { + entry: `src/index.ts`, + formats: [`es`, `cjs`], + fileName: (format) => `index.${format === `es` ? `js` : `cjs`}`, + }, + rollupOptions: { + external: [`solid-js`, `solid-js/web`, `@tanstack/db`], + }, + }, +}) diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index eea7905cd..24b9f6cac 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -62,6 +62,42 @@ import type { import type { IndexOptions } from "./indexes/index-options.js" import type { BaseIndex, IndexResolver } from "./indexes/base-index.js" +// Check for devtools registry and register collection if available +function registerWithDevtools(collection: CollectionImpl): void { + if (typeof window !== `undefined`) { + if ((window as any).__TANSTACK_DB_DEVTOOLS__?.registerCollection) { + ;(window as any).__TANSTACK_DB_DEVTOOLS__.registerCollection(collection) + ;(collection as any).isRegisteredWithDevtools = true + } else { + ;(collection as any).isRegisteredWithDevtools = false + } + } +} + +// Helper function to trigger devtools updates +function triggerDevtoolsUpdate( + collection: CollectionImpl +): void { + if (typeof window !== `undefined`) { + const updateCallback = (collection as any).__devtoolsUpdateCallback + if (typeof updateCallback === `function`) { + updateCallback() + } + } +} + +// Declare the devtools registry on window +declare global { + interface Window { + __TANSTACK_DB_DEVTOOLS__?: { + registerCollection: ( + collection: CollectionImpl + ) => (() => void) | undefined + unregisterCollection: (id: string) => void + } + } +} + interface PendingSyncedTransaction> { committed: boolean operations: Array> @@ -331,6 +367,7 @@ export class CollectionImpl< private gcTimeoutId: ReturnType | null = null private preloadPromise: Promise | null = null private syncCleanupFn: (() => void) | null = null + private isRegisteredWithDevtools = false /** * Register a callback to be executed when the collection first becomes ready @@ -462,6 +499,9 @@ export class CollectionImpl< this.validateStatusTransition(this._status, newStatus) this._status = newStatus + // Trigger devtools update when status changes + triggerDevtoolsUpdate(this) + // Resolve indexes when collection becomes ready if (newStatus === `ready` && !this.isIndexesResolved) { // Resolve indexes asynchronously without blocking @@ -510,6 +550,9 @@ export class CollectionImpl< this.syncedData = new Map() } + // Register with devtools if available + registerWithDevtools(this) + // Only start sync immediately if explicitly enabled if (config.startSync === true) { this.startSync() @@ -681,6 +724,15 @@ export class CollectionImpl< }) } + // Unregister from devtools if available + if ( + typeof window !== `undefined` && + (window as any).__TANSTACK_DB_DEVTOOLS__?.unregisterCollection + ) { + ;(window as any).__TANSTACK_DB_DEVTOOLS__.unregisterCollection(this.id) + this.isRegisteredWithDevtools = false + } + // Clear data this.syncedData.clear() this.syncedMetadata.clear() @@ -737,6 +789,11 @@ export class CollectionImpl< this.activeSubscribersCount++ this.cancelGCTimer() + // Re-register with devtools if not already registered (handles timing issues) + if (!this.isRegisteredWithDevtools) { + registerWithDevtools(this) + } + // Start sync if collection was cleaned up if (this._status === `cleaned-up` || this._status === `idle`) { this.startSync() @@ -872,6 +929,9 @@ export class CollectionImpl< // Emit all events if no pending sync transactions this.emitEvents(filteredEventsBySyncStatus, triggeredByUserAction) } + + // Trigger devtools update after optimistic state changes + triggerDevtoolsUpdate(this) } /** @@ -1347,6 +1407,9 @@ export class CollectionImpl< this.onFirstReadyCallbacks = [] callbacks.forEach((callback) => callback()) } + + // Trigger devtools update after sync operations + triggerDevtoolsUpdate(this) } } @@ -2369,5 +2432,8 @@ export class CollectionImpl< this.capturePreSyncVisibleState() this.recomputeOptimisticState(false) + + // Trigger devtools update after transaction state changes + triggerDevtoolsUpdate(this) } } diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index d6590c610..431fca02d 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -216,6 +216,7 @@ export function localOnlyCollectionOptions< utils: {} as LocalOnlyCollectionUtils, startSync: true, gcTime: 0, + collectionType: `local-only` as const, } } diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 43dfc5afa..2564e2d2d 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -440,6 +440,7 @@ export function localStorageCollectionOptions< clearStorage, getStorageSize, }, + collectionType: `local-storage` as const, } } diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 9d7877f5c..2c8957d87 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -383,6 +383,8 @@ export function liveQueryCollectionOptions< onUpdate: config.onUpdate, onDelete: config.onDelete, startSync: config.startSync, + // Mark as live query for devtools + collectionType: `live-query` as const, } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 28b25e0ef..6ab46519b 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -397,6 +397,11 @@ export interface CollectionConfig< * compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() */ compare?: (x: T, y: T) => number + /** + * Collection type for devtools grouping and identification + * @internal + */ + collectionType?: string /** * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 23bb3f28c..a173ce3f3 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "declaration": true, diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index d80f46bcd..ce7a0a34a 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -423,6 +423,7 @@ export function electricCollectionOptions< utils: { awaitTxId, }, + collectionType: `electric` as const, } } diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 1a3074fc9..7f3653a16 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -333,6 +333,7 @@ export function queryCollectionOptions< config: QueryCollectionConfig ): CollectionConfig & { utils: QueryCollectionUtils + collectionType: `query` } { const { queryKey, @@ -594,5 +595,6 @@ export function queryCollectionOptions< refetch, ...writeUtils, }, + collectionType: `query` as const, } } diff --git a/packages/react-db-devtools/package.json b/packages/react-db-devtools/package.json new file mode 100644 index 000000000..3d706e8c9 --- /dev/null +++ b/packages/react-db-devtools/package.json @@ -0,0 +1,97 @@ +{ + "name": "@tanstack/react-db-devtools", + "version": "0.0.1", + "description": "React wrapper for TanStack DB Devtools", + "author": "tanstack", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/db.git", + "directory": "packages/react-db-devtools" + }, + "homepage": "https://tanstack.com/db", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "types": "build/legacy/index.d.ts", + "main": "build/legacy/index.cjs", + "module": "build/legacy/index.js", + "exports": { + ".": { + "@tanstack/custom-condition": "./src/index.ts", + "import": { + "types": "./build/modern/index.d.ts", + "default": "./build/modern/index.js" + }, + "require": { + "types": "./build/modern/index.d.cts", + "default": "./build/modern/index.cjs" + } + }, + "./production": { + "import": { + "types": "./build/modern/production.d.ts", + "default": "./build/modern/production.js" + }, + "require": { + "types": "./build/modern/production.d.cts", + "default": "./build/modern/production.cjs" + } + }, + "./build/modern/production.js": { + "import": { + "types": "./build/modern/production.d.ts", + "default": "./build/modern/production.js" + }, + "require": { + "types": "./build/modern/production.d.cts", + "default": "./build/modern/production.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "build", + "src", + "!src/__tests__" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "clean": "premove ./build ./coverage ./dist-ts", + "compile": "tsc --build", + "test:eslint": "eslint ./src", + "test:types": "npm-run-all --serial test:types:*", + "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build tsconfig.legacy.json", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build tsconfig.legacy.json", + "test:types:tscurrent": "tsc --build", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", + "test:build": "publint --strict && attw --pack", + "lint": "eslint . --fix", + "build": "tsup --tsconfig tsconfig.prod.json", + "build:dev": "tsup --watch" + }, + "dependencies": { + "@tanstack/db-devtools": "workspace:*" + }, + "devDependencies": { + "@tanstack/react-db": "workspace:*", + "@testing-library/react": "^16.1.0", + "@types/react": "^19.0.1", + "@vitejs/plugin-react": "^4.3.4", + "npm-run-all2": "^5.0.0", + "react": "^19.0.0" + }, + "peerDependencies": { + "@tanstack/react-db": "workspace:^", + "react": "^18 || ^19" + } +} \ No newline at end of file diff --git a/packages/react-db-devtools/src/ReactDbDevtools.tsx b/packages/react-db-devtools/src/ReactDbDevtools.tsx new file mode 100644 index 000000000..1214ee654 --- /dev/null +++ b/packages/react-db-devtools/src/ReactDbDevtools.tsx @@ -0,0 +1,103 @@ +"use client" +import { useEffect, useRef, useState } from "react" +import { TanstackDbDevtools } from "@tanstack/db-devtools" +import { initializeDbDevtools } from "./index" +import type { TanstackDbDevtoolsConfig } from "@tanstack/db-devtools" + +export interface TanStackReactDbDevtoolsProps extends TanstackDbDevtoolsConfig { + // Additional React-specific props if needed +} + +export function TanStackReactDbDevtools( + props: TanStackReactDbDevtoolsProps = {} +) { + const ref = useRef(null) + const [devtools, setDevtools] = useState(null) + const initializingRef = useRef(false) + + // Initialize devtools only on client side + useEffect(() => { + if ( + typeof window === `undefined` || + !ref.current || + initializingRef.current + ) { + return + } + + // Set flag to prevent multiple initializations + initializingRef.current = true + + // Note: Devtools registry is now initialized in collections.ts before collections are created + initializeDbDevtools() + const devtoolsInstance = new TanstackDbDevtools(props) + + try { + // Mount the devtools to the DOM element + devtoolsInstance.mount(ref.current) + setDevtools(devtoolsInstance) + } catch { + initializingRef.current = false // Reset flag on error + } + + return () => { + try { + // Only unmount if the devtools were successfully mounted + devtoolsInstance.unmount() + } catch { + // Ignore unmount errors if devtools weren't mounted + } + initializingRef.current = false + } + }, []) // Empty dependency array to prevent remounting + + // Update devtools when props change + useEffect(() => { + if (!devtools) return + + if (props.initialIsOpen !== undefined) { + devtools.setInitialIsOpen(props.initialIsOpen) + } + + if (props.position !== undefined) { + devtools.setPosition(props.position) + } + + if (props.panelProps !== undefined) { + devtools.setPanelProps(props.panelProps) + } + + if (props.toggleButtonProps !== undefined) { + devtools.setToggleButtonProps(props.toggleButtonProps) + } + + if (props.closeButtonProps !== undefined) { + devtools.setCloseButtonProps(props.closeButtonProps) + } + + if (props.storageKey !== undefined) { + devtools.setStorageKey(props.storageKey) + } + + if (props.panelState !== undefined) { + devtools.setPanelState(props.panelState) + } + + if (props.onPanelStateChange !== undefined) { + devtools.setOnPanelStateChange(props.onPanelStateChange) + } + }, [ + devtools, + props.initialIsOpen, + props.position, + props.panelProps, + props.toggleButtonProps, + props.closeButtonProps, + props.storageKey, + props.panelState, + props.onPanelStateChange, + ]) + + // Render a container div for the devtools + return
+} diff --git a/packages/react-db-devtools/src/index.ts b/packages/react-db-devtools/src/index.ts new file mode 100644 index 000000000..c86e6d77f --- /dev/null +++ b/packages/react-db-devtools/src/index.ts @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" + +function DevtoolsWrapper(props: any) { + const [isClient, setIsClient] = React.useState(false) + const [DevtoolsComponent, setDevtoolsComponent] = + React.useState | null>(null) + + React.useEffect(() => { + // Only run on client after hydration + setIsClient(true) + + // Dynamically import the devtools component + import(`./ReactDbDevtools`).then((module) => { + setDevtoolsComponent(() => module.TanStackReactDbDevtools) + }) + }, []) + + // Always render null during SSR and initial client render to prevent hydration mismatch + if (!isClient || !DevtoolsComponent) { + return null + } + + return React.createElement(DevtoolsComponent, props) +} + +// Follow TanStack Query devtools pattern exactly +export const TanStackReactDbDevtools: React.ComponentType = + typeof window === `undefined` || process.env.NODE_ENV !== `development` + ? () => null + : DevtoolsWrapper + +export type { TanStackReactDbDevtoolsProps } from "./ReactDbDevtools" + +// SSR-safe initialization function - just call the core devtools +export function initializeDbDevtools(): void { + // SSR safety check + if (typeof window === `undefined`) { + return + } + + // Just import and call the core devtools initialization + import(`@tanstack/db-devtools`) + .then((module) => { + module.initializeDbDevtools() + }) + .catch(() => { + // Silently fail if core devtools module can't be loaded + }) +} diff --git a/packages/react-db-devtools/tsconfig.json b/packages/react-db-devtools/tsconfig.json new file mode 100644 index 000000000..cf93cefca --- /dev/null +++ b/packages/react-db-devtools/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2020" + }, + "include": ["src/**/*", "*.config.ts"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file diff --git a/packages/react-db-devtools/tsconfig.prod.json b/packages/react-db-devtools/tsconfig.prod.json new file mode 100644 index 000000000..d9b965512 --- /dev/null +++ b/packages/react-db-devtools/tsconfig.prod.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2020" + }, + "exclude": ["src/__tests__", "**/*.test.ts", "**/*.test.tsx"] +} \ No newline at end of file diff --git a/packages/react-db-devtools/tsup.config.ts b/packages/react-db-devtools/tsup.config.ts new file mode 100644 index 000000000..c9becf00f --- /dev/null +++ b/packages/react-db-devtools/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "tsup" + +export default defineConfig([ + { + entry: [`src/*.ts`, `src/*.tsx`], + format: [`esm`, `cjs`], + dts: true, + sourcemap: true, + clean: true, + external: [ + `react`, + `react-dom`, + `@tanstack/react-db`, + `@tanstack/db-devtools`, + ], + outDir: `build/modern`, + }, + { + entry: [`src/*.ts`, `src/*.tsx`], + format: [`esm`, `cjs`], + dts: true, + sourcemap: true, + clean: false, + external: [ + `react`, + `react-dom`, + `@tanstack/react-db`, + `@tanstack/db-devtools`, + ], + outDir: `build/legacy`, + }, +]) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 88519ec1c..c41c3b089 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -291,6 +291,7 @@ export function trailBaseCollectionOptions< return { ...config, + collectionType: `trailbase` as const, sync, getKey, onInsert: async ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc024c9bb..79e56620e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,7 +88,7 @@ importers: version: 0.4.0 tsup: specifier: ^8.0.2 - version: 8.5.0(@microsoft/api-extractor@7.47.7(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0) + version: 8.5.0(@microsoft/api-extractor@7.52.10(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0) typescript: specifier: ^5.8.2 version: 5.8.3 @@ -243,6 +243,9 @@ importers: examples/react/todo: dependencies: + '@tanstack/db-devtools': + specifier: workspace:* + version: link:../../../packages/db-devtools '@tanstack/electric-db-collection': specifier: ^0.1.0 version: link:../../../packages/electric-db-collection @@ -255,14 +258,20 @@ importers: '@tanstack/react-db': specifier: ^0.1.0 version: link:../../../packages/react-db + '@tanstack/react-db-devtools': + specifier: workspace:* + version: link:../../../packages/react-db-devtools '@tanstack/react-router': specifier: ^1.125.6 version: 1.130.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@tanstack/react-router-devtools': + specifier: ^1.130.2 + version: 1.130.2(@tanstack/react-router@1.130.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@tanstack/router-core@1.130.2)(csstype@3.1.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(solid-js@1.9.7)(tiny-invariant@1.3.3) '@tanstack/react-start': specifier: ^1.126.1 version: 1.130.3(@netlify/blobs@9.1.2)(@tanstack/react-router@1.130.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(drizzle-orm@0.40.1(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.3)(pg@8.16.3)(postgres@3.4.7))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite-plugin-solid@2.11.8(@testing-library/jest-dom@6.6.4)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/trailbase-db-collection': - specifier: ^0.1.0 + specifier: ^0.1.2 version: link:../../../packages/trailbase-db-collection cors: specifier: ^2.8.5 @@ -487,6 +496,51 @@ importers: specifier: ^2.1.20 version: 2.1.20 + packages/db-devtools: + devDependencies: + '@kobalte/core': + specifier: ^0.13.4 + version: 0.13.11(solid-js@1.9.7) + '@solid-primitives/keyed': + specifier: ^1.2.2 + version: 1.5.2(solid-js@1.9.7) + '@solid-primitives/resize-observer': + specifier: ^2.0.26 + version: 2.1.3(solid-js@1.9.7) + '@solid-primitives/storage': + specifier: ^1.3.11 + version: 1.3.11(solid-js@1.9.7) + '@tanstack/match-sorter-utils': + specifier: ^8.19.4 + version: 8.19.4 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + goober: + specifier: ^2.1.16 + version: 2.1.16(csstype@3.1.3) + npm-run-all2: + specifier: ^5.0.0 + version: 5.0.2 + solid-js: + specifier: ^1.9.5 + version: 1.9.7 + solid-transition-group: + specifier: ^0.2.3 + version: 0.2.3(solid-js@1.9.7) + superjson: + specifier: ^2.2.1 + version: 2.2.2 + tsup-preset-solid: + specifier: ^2.2.0 + version: 2.2.0(esbuild@0.25.8)(solid-js@1.9.7)(tsup@8.5.0(@microsoft/api-extractor@7.52.10(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@22.17.0)(rollup@4.46.1)(typescript@5.8.3)(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + vite-plugin-solid: + specifier: ^2.11.6 + version: 2.11.8(@testing-library/jest-dom@6.6.4)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + packages/db-ivm: dependencies: fractional-indexing: @@ -590,6 +644,31 @@ importers: specifier: ^19.0.0 version: 19.1.1(react@19.1.1) + packages/react-db-devtools: + dependencies: + '@tanstack/db-devtools': + specifier: workspace:* + version: link:../db-devtools + devDependencies: + '@tanstack/react-db': + specifier: workspace:* + version: link:../react-db + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@types/react': + specifier: ^19.0.1 + version: 19.1.9 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + npm-run-all2: + specifier: ^5.0.0 + version: 5.0.2 + react: + specifier: ^19.0.0 + version: 19.1.1 + packages/solid-db: dependencies: '@solid-primitives/map': @@ -948,6 +1027,11 @@ packages: resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} engines: {node: '>=v18'} + '@corvu/utils@0.4.2': + resolution: {integrity: sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==} + peerDependencies: + solid-js: ^1.8 + '@csstools/color-helpers@5.0.2': resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} engines: {node: '>=18'} @@ -1635,6 +1719,15 @@ packages: '@fastify/busboy@3.1.1': resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.3': + resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@gerrit0/mini-shiki@1.27.2': resolution: {integrity: sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==} @@ -1661,6 +1754,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@internationalized/date@3.8.2': + resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} + + '@internationalized/number@3.6.4': + resolution: {integrity: sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==} + '@ioredis/commands@1.3.0': resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} @@ -1700,6 +1799,16 @@ packages: '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@kobalte/core@0.13.11': + resolution: {integrity: sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ==} + peerDependencies: + solid-js: ^1.8.15 + + '@kobalte/utils@0.9.1': + resolution: {integrity: sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w==} + peerDependencies: + solid-js: ^1.8.8 + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -1723,10 +1832,17 @@ packages: '@microsoft/api-extractor-model@7.29.6': resolution: {integrity: sha512-gC0KGtrZvxzf/Rt9oMYD2dHvtN/1KPEYsrQPyMKhLHnlVuO/f4AFN3E4toqZzD2pt4LhkKoYmL2H9tX3yCOyRw==} + '@microsoft/api-extractor-model@7.30.7': + resolution: {integrity: sha512-TBbmSI2/BHpfR9YhQA7nH0nqVmGgJ0xH0Ex4D99/qBDAUpnhA2oikGmdXanbw9AWWY/ExBYIpkmY8dBHdla3YQ==} + '@microsoft/api-extractor@7.47.7': resolution: {integrity: sha512-fNiD3G55ZJGhPOBPMKD/enozj8yxJSYyVJWxRWdcUtw842rvthDHJgUWq9gXQTensFlMHv2wGuCjjivPv53j0A==} hasBin: true + '@microsoft/api-extractor@7.52.10': + resolution: {integrity: sha512-LhKytJM5ZJkbHQVfW/3o747rZUNs/MGg6j/wt/9qwwqEOfvUDTYXXxIBuMgrRXhJ528p41iyz4zjBVHZU74Odg==} + hasBin: true + '@microsoft/tsdoc-config@0.17.1': resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} @@ -2112,6 +2228,14 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@5.14.0': + resolution: {integrity: sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + '@rushstack/node-core-library@5.7.0': resolution: {integrity: sha512-Ff9Cz/YlWu9ce4dmqNBZpA45AEya04XaBFIjV7xTVeEf+y/kTjEasmozqFELXlNG4ROdevss75JrrZ5WgufDkQ==} peerDependencies: @@ -2131,9 +2255,20 @@ packages: '@types/node': optional: true + '@rushstack/terminal@0.15.4': + resolution: {integrity: sha512-OQSThV0itlwVNHV6thoXiAYZlQh4Fgvie2CzxFABsbO2MWQsI4zOh3LRNigYSTrmS+ba2j0B3EObakPzf/x6Zg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + '@rushstack/ts-command-line@4.22.6': resolution: {integrity: sha512-QSRqHT/IfoC5nk9zn6+fgyqOPXHME0BfchII9EUPR19pocsNp/xSbeBCbD3PIR2Lg+Q5qk7OFqk1VhWPMdKHJg==} + '@rushstack/ts-command-line@5.0.2': + resolution: {integrity: sha512-+AkJDbu1GFMPIU8Sb7TLVXDv/Q7Mkvx+wAjEl8XiXVVq+p1FmWW6M3LYpJMmoHNckSofeMecgWg5lfMwNAAsEQ==} + '@shikijs/engine-oniguruma@1.29.2': resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==} @@ -2188,6 +2323,16 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/keyed@1.5.2': + resolution: {integrity: sha512-BgoEdqPw48URnI+L5sZIHdF4ua4Las1eWEBBPaoSFs42kkhnHue+rwCBPL2Z9ebOyQ75sUhUfOETdJfmv0D6Kg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/map@0.4.13': + resolution: {integrity: sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/map@0.7.2': resolution: {integrity: sha512-sXK/rS68B4oq3XXNyLrzVhLtT1pnimmMUahd2FqhtYUuyQsCfnW058ptO1s+lWc2k8F/3zQSNVkZ2ifJjlcNbQ==} peerDependencies: @@ -2198,6 +2343,11 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/props@3.2.2': + resolution: {integrity: sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/refs@1.1.2': resolution: {integrity: sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==} peerDependencies: @@ -2223,11 +2373,21 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/storage@1.3.11': + resolution: {integrity: sha512-PpQWR3TaTxHIJFbI9ZssYTM4Aa67g1vJIgps4TPhcXzHqqomrPAIveFC2FG7SDQoi9YQia8FVBjigELziJpfIg==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/styles@0.1.2': resolution: {integrity: sha512-7iX5K+J5b1PRrbgw3Ki92uvU2LgQ0Kd/QMsrAZxDg5dpUBwMyTijZkA3bbs1ikZsT1oQhS41bTyKbjrXeU0Awg==} peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/transition-group@1.1.2': + resolution: {integrity: sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/trigger@1.2.2': resolution: {integrity: sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g==} peerDependencies: @@ -2302,6 +2462,9 @@ packages: resolution: {integrity: sha512-08eKiDAjj4zLug1taXSIJ0kGL5cawjVCyJkBb6EWSg5fEPX6L+Wtr0CH2If4j5KYylz85iaZiFlUItvgJvll5g==} engines: {node: ^14.13.1 || ^16.0.0 || >=18} + '@swc/helpers@0.5.17': + resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tailwindcss/node@4.1.11': resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==} @@ -2426,6 +2589,10 @@ packages: resolution: {integrity: sha512-I3YTkbe4RZQN54Qw4+IUhOjqG2DdbG2+EBWuQfew4MEk0eddLYAQVa50BZVww4/D2eh5I9vEk2Fd1Y0Wty7pug==} engines: {node: '>=12'} + '@tanstack/match-sorter-utils@8.19.4': + resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} + engines: {node: '>=12'} + '@tanstack/publish-config@0.2.0': resolution: {integrity: sha512-RC0yRBFJvGuR58tKQUIkMXVEiATXgESIc+3/NTqoCC7D2YOF4fZGmHGYIanFEPQH7EGfQ5+Bwi+H6BOtKnymtw==} engines: {node: '>=18'} @@ -3050,6 +3217,14 @@ packages: typescript: optional: true + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@vue/reactivity@3.5.18': resolution: {integrity: sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==} @@ -3147,6 +3322,9 @@ packages: ajv@8.13.0: resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -3609,6 +3787,10 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + copy-file@11.0.0: resolution: {integrity: sha512-mFsNh/DIANLqFt5VHZoGirdg7bK5+oTWlhnGu6tgRhzBlnEKWaPX2xrFaLltii/6rmhqFMJqffUgknuRdpYlHw==} engines: {node: '>=18'} @@ -4155,6 +4337,9 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -4193,6 +4378,12 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild-plugin-solid@0.5.0: + resolution: {integrity: sha512-ITK6n+0ayGFeDVUZWNMxX+vLsasEN1ILrg4pISsNOQ+mq4ljlJJiuXotInd+HE0MzwTcA9wExT1yzDE2hsqPsg==} + peerDependencies: + esbuild: '>=0.12' + solid-js: '>= 1.0' + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -4552,6 +4743,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -4733,6 +4928,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -4857,6 +5055,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} @@ -5159,6 +5360,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5452,6 +5656,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -5680,6 +5888,9 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5692,6 +5903,11 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-all2@5.0.2: + resolution: {integrity: sha512-S2G6FWZ3pNWAAKm2PFSOtEAG/N+XO/kz3+9l6V91IY+Y3XFSt7Lp7DV92KCgEboEW0hRTu0vFaMe4zXDZYaOyA==} + engines: {node: '>= 10'} + hasBin: true + npm-run-path@2.0.2: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} @@ -5858,6 +6074,10 @@ packages: resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} engines: {node: '>=14'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse-json@8.3.0: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} @@ -5980,6 +6200,11 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pidtree@0.5.0: + resolution: {integrity: sha512-9nxspIM7OpZuhBxPg73Zvyq7j1QMPMPsGKTqRc2XOaFQauDvoNz9fM1Wdkjmeo7l9GXOZiRs97sPkuayl39wjA==} + engines: {node: '>=0.10'} + hasBin: true + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -6179,6 +6404,10 @@ packages: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} + read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + read-pkg@9.0.1: resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} engines: {node: '>=18'} @@ -6237,6 +6466,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + remove-accents@0.5.0: + resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} + remove-trailing-separator@1.1.0: resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} @@ -6508,11 +6740,27 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + solid-presence@0.1.8: + resolution: {integrity: sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA==} + peerDependencies: + solid-js: ^1.8 + + solid-prevent-scroll@0.1.10: + resolution: {integrity: sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw==} + peerDependencies: + solid-js: ^1.8 + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: solid-js: ^1.3 + solid-transition-group@0.2.3: + resolution: {integrity: sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==} + engines: {node: '>=18.0.0', pnpm: '>=8.6.0'} + peerDependencies: + solid-js: ^1.6.12 + sorted-btree@1.8.1: resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} @@ -6669,6 +6917,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superjson@2.2.2: + resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} + engines: {node: '>=16'} + supports-color@10.0.0: resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==} engines: {node: '>=18'} @@ -6873,6 +7125,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup-preset-solid@2.2.0: + resolution: {integrity: sha512-sPAzeArmYkVAZNRN+m4tkiojdd0GzW/lCwd4+TQDKMENe8wr2uAuro1s0Z59ASmdBbkXoxLgCiNcuQMyiidMZg==} + peerDependencies: + tsup: ^8.0.0 + tsup@8.5.0: resolution: {integrity: sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==} engines: {node: '>=18'} @@ -6901,6 +7158,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -6955,6 +7216,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -7164,6 +7430,15 @@ packages: vite: optional: true + vite-plugin-dts@4.5.4: + resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite-plugin-externalize-deps@0.9.0: resolution: {integrity: sha512-wg3qb5gCy2d1KpPKyD9wkXMcYJ84yjgziHrStq9/8R7chhUC73mhQz+tVtvhFiICQHsBn1pnkY4IBbPqF9JHNw==} peerDependencies: @@ -7889,6 +8164,11 @@ snapshots: '@types/conventional-commits-parser': 5.0.1 chalk: 5.4.1 + '@corvu/utils@0.4.2(solid-js@1.9.7)': + dependencies: + '@floating-ui/dom': 1.7.3 + solid-js: 1.9.7 + '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -8296,6 +8576,17 @@ snapshots: '@fastify/busboy@3.1.1': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.3': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@gerrit0/mini-shiki@1.27.2': dependencies: '@shikijs/engine-oniguruma': 1.29.2 @@ -8317,6 +8608,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@internationalized/date@3.8.2': + dependencies: + '@swc/helpers': 0.5.17 + + '@internationalized/number@3.6.4': + dependencies: + '@swc/helpers': 0.5.17 + '@ioredis/commands@1.3.0': {} '@isaacs/balanced-match@4.0.1': {} @@ -8359,6 +8658,29 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@kobalte/core@0.13.11(solid-js@1.9.7)': + dependencies: + '@floating-ui/dom': 1.7.3 + '@internationalized/date': 3.8.2 + '@internationalized/number': 3.6.4 + '@kobalte/utils': 0.9.1(solid-js@1.9.7) + '@solid-primitives/props': 3.2.2(solid-js@1.9.7) + '@solid-primitives/resize-observer': 2.1.3(solid-js@1.9.7) + solid-js: 1.9.7 + solid-presence: 0.1.8(solid-js@1.9.7) + solid-prevent-scroll: 0.1.10(solid-js@1.9.7) + + '@kobalte/utils@0.9.1(solid-js@1.9.7)': + dependencies: + '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.7) + '@solid-primitives/keyed': 1.5.2(solid-js@1.9.7) + '@solid-primitives/map': 0.4.13(solid-js@1.9.7) + '@solid-primitives/media': 2.3.3(solid-js@1.9.7) + '@solid-primitives/props': 3.2.2(solid-js@1.9.7) + '@solid-primitives/refs': 1.1.2(solid-js@1.9.7) + '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) + solid-js: 1.9.7 + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.1 @@ -8406,6 +8728,14 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/api-extractor-model@7.30.7(@types/node@22.17.0)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.14.0(@types/node@22.17.0) + transitivePeerDependencies: + - '@types/node' + '@microsoft/api-extractor@7.47.7(@types/node@22.17.0)': dependencies: '@microsoft/api-extractor-model': 7.29.6(@types/node@22.17.0) @@ -8424,6 +8754,24 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/api-extractor@7.52.10(@types/node@22.17.0)': + dependencies: + '@microsoft/api-extractor-model': 7.30.7(@types/node@22.17.0) + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.14.0(@types/node@22.17.0) + '@rushstack/rig-package': 0.5.3 + '@rushstack/terminal': 0.15.4(@types/node@22.17.0) + '@rushstack/ts-command-line': 5.0.2(@types/node@22.17.0) + lodash: 4.17.21 + minimatch: 10.0.3 + resolve: 1.22.10 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.8.2 + transitivePeerDependencies: + - '@types/node' + '@microsoft/tsdoc-config@0.17.1': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -8807,6 +9155,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.1': optional: true + '@rushstack/node-core-library@5.14.0(@types/node@22.17.0)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.10 + semver: 7.5.4 + optionalDependencies: + '@types/node': 22.17.0 + '@rushstack/node-core-library@5.7.0(@types/node@22.17.0)': dependencies: ajv: 8.13.0 @@ -8832,6 +9193,13 @@ snapshots: optionalDependencies: '@types/node': 22.17.0 + '@rushstack/terminal@0.15.4(@types/node@22.17.0)': + dependencies: + '@rushstack/node-core-library': 5.14.0(@types/node@22.17.0) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 22.17.0 + '@rushstack/ts-command-line@4.22.6(@types/node@22.17.0)': dependencies: '@rushstack/terminal': 0.14.0(@types/node@22.17.0) @@ -8841,6 +9209,15 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@rushstack/ts-command-line@5.0.2(@types/node@22.17.0)': + dependencies: + '@rushstack/terminal': 0.15.4(@types/node@22.17.0) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@shikijs/engine-oniguruma@1.29.2': dependencies: '@shikijs/types': 1.29.2 @@ -8923,6 +9300,15 @@ snapshots: '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) solid-js: 1.9.7 + '@solid-primitives/keyed@1.5.2(solid-js@1.9.7)': + dependencies: + solid-js: 1.9.7 + + '@solid-primitives/map@0.4.13(solid-js@1.9.7)': + dependencies: + '@solid-primitives/trigger': 1.2.2(solid-js@1.9.7) + solid-js: 1.9.7 + '@solid-primitives/map@0.7.2(solid-js@1.9.7)': dependencies: '@solid-primitives/trigger': 1.2.2(solid-js@1.9.7) @@ -8936,6 +9322,11 @@ snapshots: '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) solid-js: 1.9.7 + '@solid-primitives/props@3.2.2(solid-js@1.9.7)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) + solid-js: 1.9.7 + '@solid-primitives/refs@1.1.2(solid-js@1.9.7)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) @@ -8963,12 +9354,21 @@ snapshots: '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) solid-js: 1.9.7 + '@solid-primitives/storage@1.3.11(solid-js@1.9.7)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) + solid-js: 1.9.7 + '@solid-primitives/styles@0.1.2(solid-js@1.9.7)': dependencies: '@solid-primitives/rootless': 1.5.2(solid-js@1.9.7) '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) solid-js: 1.9.7 + '@solid-primitives/transition-group@1.1.2(solid-js@1.9.7)': + dependencies: + solid-js: 1.9.7 + '@solid-primitives/trigger@1.2.2(solid-js@1.9.7)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.7) @@ -9057,6 +9457,10 @@ snapshots: transitivePeerDependencies: - encoding + '@swc/helpers@0.5.17': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.1.11': dependencies: '@ampproject/remapping': 2.3.0 @@ -9201,6 +9605,10 @@ snapshots: '@tanstack/history@1.129.7': {} + '@tanstack/match-sorter-utils@8.19.4': + dependencies: + remove-accents: 0.5.0 + '@tanstack/publish-config@0.2.0': dependencies: '@commitlint/parse': 19.8.1 @@ -10344,6 +10752,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.22 + '@vue/compiler-dom': 3.5.18 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.18 + alien-signals: 0.4.14 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/reactivity@3.5.18': dependencies: '@vue/shared': 3.5.18 @@ -10453,6 +10874,8 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + alien-signals@0.4.14: {} + ansi-colors@4.1.3: {} ansi-escapes@7.0.0: @@ -10977,6 +11400,10 @@ snapshots: cookie@1.0.2: {} + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + copy-file@11.0.0: dependencies: graceful-fs: 4.2.11 @@ -11333,6 +11760,10 @@ snapshots: environment@1.1.0: {} + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} es-abstract@1.24.0: @@ -11438,6 +11869,16 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild-plugin-solid@0.5.0(esbuild@0.25.8)(solid-js@1.9.7): + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + babel-preset-solid: 1.9.6(@babel/core@7.28.0) + esbuild: 0.25.8 + solid-js: 1.9.7 + transitivePeerDependencies: + - supports-color + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: debug: 4.4.1 @@ -11985,6 +12426,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -12201,6 +12648,8 @@ snapshots: hookable@5.5.3: {} + hosted-git-info@2.8.9: {} + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -12321,6 +12770,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-arrayish@0.3.2: {} is-async-function@2.1.1: @@ -12614,6 +13065,8 @@ snapshots: json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -12907,6 +13360,8 @@ snapshots: media-typer@0.3.0: {} + memorystream@0.3.1: {} + meow@12.1.1: {} merge-anything@5.1.7: @@ -13274,6 +13729,13 @@ snapshots: dependencies: abbrev: 3.0.1 + normalize-package-data@2.5.0: + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.10 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -13286,6 +13748,16 @@ snapshots: normalize-path@3.0.0: {} + npm-run-all2@5.0.2: + dependencies: + ansi-styles: 5.2.0 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + minimatch: 3.1.2 + pidtree: 0.5.0 + read-pkg: 5.2.0 + shell-quote: 1.8.3 + npm-run-path@2.0.2: dependencies: path-key: 2.0.1 @@ -13459,9 +13931,16 @@ snapshots: parse-gitignore@2.0.0: {} + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse-json@8.3.0: dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 index-to-position: 1.1.0 type-fest: 4.41.0 @@ -13561,6 +14040,8 @@ snapshots: picomatch@4.0.3: {} + pidtree@0.5.0: {} + pidtree@0.6.0: {} pify@4.0.1: {} @@ -13747,6 +14228,13 @@ snapshots: read-pkg: 9.0.1 type-fest: 4.41.0 + read-pkg@5.2.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + read-pkg@9.0.1: dependencies: '@types/normalize-package-data': 2.4.4 @@ -13839,6 +14327,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + remove-accents@0.5.0: {} + remove-trailing-separator@1.1.0: {} require-directory@2.1.1: {} @@ -14162,6 +14652,16 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) + solid-presence@0.1.8(solid-js@1.9.7): + dependencies: + '@corvu/utils': 0.4.2(solid-js@1.9.7) + solid-js: 1.9.7 + + solid-prevent-scroll@0.1.10(solid-js@1.9.7): + dependencies: + '@corvu/utils': 0.4.2(solid-js@1.9.7) + solid-js: 1.9.7 + solid-refresh@0.6.3(solid-js@1.9.7): dependencies: '@babel/generator': 7.28.0 @@ -14171,6 +14671,12 @@ snapshots: transitivePeerDependencies: - supports-color + solid-transition-group@0.2.3(solid-js@1.9.7): + dependencies: + '@solid-primitives/refs': 1.1.2(solid-js@1.9.7) + '@solid-primitives/transition-group': 1.1.2(solid-js@1.9.7) + solid-js: 1.9.7 + sorted-btree@1.8.1: {} source-map-js@1.2.1: {} @@ -14347,6 +14853,10 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + superjson@2.2.2: + dependencies: + copy-anything: 3.0.5 + supports-color@10.0.0: {} supports-color@7.2.0: @@ -14540,7 +15050,16 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(@microsoft/api-extractor@7.47.7(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0): + tsup-preset-solid@2.2.0(esbuild@0.25.8)(solid-js@1.9.7)(tsup@8.5.0(@microsoft/api-extractor@7.52.10(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)): + dependencies: + esbuild-plugin-solid: 0.5.0(esbuild@0.25.8)(solid-js@1.9.7) + tsup: 8.5.0(@microsoft/api-extractor@7.52.10(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0) + transitivePeerDependencies: + - esbuild + - solid-js + - supports-color + + tsup@8.5.0(@microsoft/api-extractor@7.52.10(@types/node@22.17.0))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.8) cac: 6.7.14 @@ -14560,7 +15079,7 @@ snapshots: tinyglobby: 0.2.14 tree-kill: 1.2.2 optionalDependencies: - '@microsoft/api-extractor': 7.47.7(@types/node@22.17.0) + '@microsoft/api-extractor': 7.52.10(@types/node@22.17.0) postcss: 8.5.6 typescript: 5.8.3 transitivePeerDependencies: @@ -14580,6 +15099,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.6.0: {} + type-fest@4.41.0: {} type-is@1.6.18: @@ -14651,6 +15172,8 @@ snapshots: typescript@5.4.2: {} + typescript@5.8.2: {} + typescript@5.8.3: {} uc.micro@2.1.0: {} @@ -14894,6 +15417,25 @@ snapshots: - rollup - supports-color + vite-plugin-dts@4.5.4(@types/node@22.17.0)(rollup@4.46.1)(typescript@5.8.3)(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): + dependencies: + '@microsoft/api-extractor': 7.52.10(@types/node@22.17.0) + '@rollup/pluginutils': 5.2.0(rollup@4.46.1) + '@volar/typescript': 2.4.22 + '@vue/language-core': 2.2.0(typescript@5.8.3) + compare-versions: 6.1.1 + debug: 4.4.1 + kolorist: 1.8.0 + local-pkg: 1.1.1 + magic-string: 0.30.17 + typescript: 5.8.3 + optionalDependencies: + vite: 6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-externalize-deps@0.9.0(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): dependencies: vite: 6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) From eaa5903171dbf10b864acc0331e6299b36fff5f4 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 13 Aug 2025 16:30:05 +0100 Subject: [PATCH 02/18] working devtools in tanstack devtools wrapper --- examples/react/todo/package.json | 1 + examples/react/todo/src/routes/__root.tsx | 22 ++- .../db-devtools/src/TanstackDbDevtools.tsx | 5 + packages/db-devtools/src/index.ts | 1 + packages/db-devtools/src/registry.ts | 15 +- packages/react-db-devtools/package.json | 3 +- .../src/ReactDbDevtoolsPanel.tsx | 142 ++++++++++++++++++ packages/react-db-devtools/src/index.ts | 2 + pnpm-lock.yaml | 71 +++++++++ 9 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx diff --git a/examples/react/todo/package.json b/examples/react/todo/package.json index bac0a0bde..5df2a8f61 100644 --- a/examples/react/todo/package.json +++ b/examples/react/todo/package.json @@ -9,6 +9,7 @@ "@tanstack/query-db-collection": "^0.2.0", "@tanstack/react-db": "^0.1.0", "@tanstack/react-db-devtools": "workspace:*", + "@tanstack/react-devtools": "^0.3.0", "@tanstack/react-router": "^1.125.6", "@tanstack/react-router-devtools": "^1.130.2", "@tanstack/react-start": "^1.126.1", diff --git a/examples/react/todo/src/routes/__root.tsx b/examples/react/todo/src/routes/__root.tsx index b4f3fc20e..446009dca 100644 --- a/examples/react/todo/src/routes/__root.tsx +++ b/examples/react/todo/src/routes/__root.tsx @@ -4,8 +4,10 @@ import { Scripts, createRootRoute, } from "@tanstack/react-router" -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' -import { TanStackReactDbDevtools } from "@tanstack/react-db-devtools" +import { TanstackDevtools } from "@tanstack/react-devtools" +import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools" +import { TanStackReactDbDevtoolsPanel } from "@tanstack/react-db-devtools" +// import { TanStackReactDbDevtools } from "@tanstack/react-db-devtools" import appCss from "../styles.css?url" @@ -34,8 +36,20 @@ export const Route = createRootRoute({ component: () => ( - - + , + }, + { + name: "Tanstack DB", + render: , + }, + ]} + /> + {/* Alternative standalone component */} + {/* */} ), }) diff --git a/packages/db-devtools/src/TanstackDbDevtools.tsx b/packages/db-devtools/src/TanstackDbDevtools.tsx index 58b7f0c09..1565b5356 100644 --- a/packages/db-devtools/src/TanstackDbDevtools.tsx +++ b/packages/db-devtools/src/TanstackDbDevtools.tsx @@ -39,6 +39,11 @@ class TanstackDbDevtools { shadowDOMTarget, } = config + // Only initialize on the client side + if (typeof window === `undefined`) { + throw new Error('TanstackDbDevtools cannot be instantiated during SSR') + } + this.#registry = initializeDevtoolsRegistry() this.#shadowDOMTarget = shadowDOMTarget this.#initialIsOpen = createSignal(initialIsOpen) diff --git a/packages/db-devtools/src/index.ts b/packages/db-devtools/src/index.ts index af09fec3d..4b28a2f21 100644 --- a/packages/db-devtools/src/index.ts +++ b/packages/db-devtools/src/index.ts @@ -3,6 +3,7 @@ export * from "./types" export * from "./constants" export * from "./devtools" export * from "./registry" +export * from "./BaseTanStackDbDevtoolsPanel" // Components export { Explorer } from "./components/Explorer" diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index 73d02509a..d49343ba4 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -343,16 +343,16 @@ export function createDbDevtoolsRegistry(): DbDevtoolsRegistry { // Initialize the global registry if not already present export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { - // SSR safety check + // SSR safety check - return a no-op registry for server-side rendering if (typeof window === `undefined`) { - // Return a no-op registry for server-side rendering - const [collectionsSignal] = createSignal>([]) - const [transactionsSignal] = createSignal>([]) - + // Create dummy signals that won't be used during SSR + const dummySignal = () => [] + dummySignal.set = () => {} + return { collections: new Map(), - collectionsSignal, - transactionsSignal, + collectionsSignal: dummySignal as any, + transactionsSignal: dummySignal as any, registerCollection: () => undefined, unregisterCollection: () => {}, getCollection: () => undefined, @@ -372,6 +372,7 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { } as DbDevtoolsRegistry } + // Only create real signals on the client side if (!(window as any).__TANSTACK_DB_DEVTOOLS__) { ;(window as any).__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() } diff --git a/packages/react-db-devtools/package.json b/packages/react-db-devtools/package.json index 3d706e8c9..a72919a32 100644 --- a/packages/react-db-devtools/package.json +++ b/packages/react-db-devtools/package.json @@ -80,7 +80,8 @@ "build:dev": "tsup --watch" }, "dependencies": { - "@tanstack/db-devtools": "workspace:*" + "@tanstack/db-devtools": "workspace:*", + "solid-js": "^1.9.5" }, "devDependencies": { "@tanstack/react-db": "workspace:*", diff --git a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx new file mode 100644 index 000000000..aa7e42891 --- /dev/null +++ b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useRef, useState } from 'react' + +export interface ReactDbDevtoolsPanelOptions { + // Additional React-specific props if needed +} + +export const TanStackReactDbDevtoolsPanel: React.FC = ( + props, +): React.ReactElement | null => { + const { ...rest } = props + + // SSR safety check - return null during SSR + if (typeof window === `undefined`) { + return null + } + + const [isInitialized, setIsInitialized] = useState(false) + const [error, setError] = useState(null) + const [registry, setRegistry] = useState(null) + const containerRef = useRef(null) + const solidRootRef = useRef(null) + + useEffect(() => { + // Only initialize on the client side + if (typeof window === `undefined`) return + + async function init() { + try { + // Dynamically import the devtools to avoid SSR issues + const { getDevtoolsRegistry } = await import(`@tanstack/db-devtools`) + + // Get the existing registry (should already be initialized) + const existingRegistry = getDevtoolsRegistry() + + if (!existingRegistry) { + throw new Error('DB devtools registry not found. Make sure initializeDbDevtools() was called.') + } + + setRegistry(existingRegistry) + setIsInitialized(true) + } catch (error) { + console.error('Failed to initialize DB devtools:', error) + setError(error instanceof Error ? error.message : 'Unknown error') + } + } + + init() + }, []) // Empty dependency array to run only once + + useEffect(() => { + if (!isInitialized || !registry || !containerRef.current) return + + async function mountSolidComponent() { + try { + // Import SolidJS render and the base panel component + const { render } = await import('solid-js/web') + const { BaseTanStackDbDevtoolsPanel } = await import('@tanstack/db-devtools') + + // Clean up any existing component + if (solidRootRef.current) { + solidRootRef.current() + solidRootRef.current = null + } + + // Create a SolidJS component that renders the base panel + const SolidComponent = () => { + return BaseTanStackDbDevtoolsPanel({ + registry: () => registry, + style: () => ({ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }), + ...rest + }) + } + + // Render the SolidJS component into the container + const dispose = render(SolidComponent, containerRef.current!) + solidRootRef.current = dispose + } catch (error) { + console.error('Failed to mount SolidJS component:', error) + setError(error instanceof Error ? error.message : 'Unknown error') + } + } + + mountSolidComponent() + + // Cleanup function + return () => { + if (solidRootRef.current) { + try { + solidRootRef.current() + } catch (error) { + console.error('Error disposing SolidJS component:', error) + } + solidRootRef.current = null + } + } + }, [isInitialized, registry, rest]) + + // Don't render anything until we're on the client + if (typeof window === `undefined`) { + return null + } + + // Show error state if initialization failed + if (error) { + return ( +
+

Failed to load DB Devtools

+

Error: {error}

+
+ ) + } + + // Show loading state while initializing + if (!isInitialized || !registry) { + return ( +
+

Loading DB Devtools...

+
+ ) + } + + // Render a container div that the SolidJS component will be mounted into + // Use flexbox to ensure it takes up the full available height + return ( +
+ ) +} \ No newline at end of file diff --git a/packages/react-db-devtools/src/index.ts b/packages/react-db-devtools/src/index.ts index c86e6d77f..4649cb366 100644 --- a/packages/react-db-devtools/src/index.ts +++ b/packages/react-db-devtools/src/index.ts @@ -2,6 +2,8 @@ import * as React from "react" +export { TanStackReactDbDevtoolsPanel } from "./ReactDbDevtoolsPanel" + function DevtoolsWrapper(props: any) { const [isClient, setIsClient] = React.useState(false) const [DevtoolsComponent, setDevtoolsComponent] = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79e56620e..bea888cb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,9 @@ importers: '@tanstack/react-db-devtools': specifier: workspace:* version: link:../../../packages/react-db-devtools + '@tanstack/react-devtools': + specifier: ^0.3.0 + version: 0.3.0(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(csstype@3.1.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(solid-js@1.9.7) '@tanstack/react-router': specifier: ^1.125.6 version: 1.130.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -649,6 +652,9 @@ importers: '@tanstack/db-devtools': specifier: workspace:* version: link:../db-devtools + solid-js: + specifier: ^1.9.5 + version: 1.9.7 devDependencies: '@tanstack/react-db': specifier: workspace:* @@ -2569,6 +2575,22 @@ packages: peerDependencies: typescript: '>=4.7' + '@tanstack/devtools-event-bus@0.2.1': + resolution: {integrity: sha512-JMq3AmrQR2LH9P8Rcj1MTq8Iq/mPk/PyuqSw1L0hO2Wl8G1oz5ue31fS8u8lIgOCVR/mGdJah18p+Pj5OosRJA==} + engines: {node: '>=18'} + + '@tanstack/devtools-ui@0.3.0': + resolution: {integrity: sha512-lyP0eM6juIWn8zgI8xI32Lh86gCnjUyNePE9F7Bfgkv5taILmmJAHW5Mme4T2ufv7L8NLwOiBY/bZYnP4zev0w==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/devtools@0.3.1': + resolution: {integrity: sha512-dqUpPbB4CWvNsXEOyS0H/6elp/QVetvHz3f51B96zl+b5294gUjGwSMI4eNVrmBQPPmqkdkeGsbbkgrlJ1VhNQ==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + '@tanstack/directive-functions-plugin@1.129.7': resolution: {integrity: sha512-2VvlVmDvwHOnDAXQQa+gnhDnWPW59JcqePFf1ujOG0QGv+pw1G+JzHpiLZs4Dwr4myMxMGzFp5AWtvF96rpE7Q==} engines: {node: '>=12'} @@ -2615,6 +2637,15 @@ packages: peerDependencies: react: '>=16.8.0' + '@tanstack/react-devtools@0.3.0': + resolution: {integrity: sha512-16Bfxdb6lxekwY1Nl7UOzfKAIqSB2kw1neX5WUFa1g2SIZLyb3gYiSn1a42RaiJaZOAuHBOxhmad8ZBsetMumQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8' + '@types/react-dom': '>=16.8' + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-query@5.83.0': resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==} peerDependencies: @@ -9560,6 +9591,33 @@ snapshots: '@standard-schema/spec': 1.0.0 typescript: 5.8.3 + '@tanstack/devtools-event-bus@0.2.1': + dependencies: + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/devtools-ui@0.3.0(csstype@3.1.3)(solid-js@1.9.7)': + dependencies: + goober: 2.1.16(csstype@3.1.3) + solid-js: 1.9.7 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools@0.3.1(csstype@3.1.3)(solid-js@1.9.7)': + dependencies: + '@solid-primitives/keyboard': 1.3.3(solid-js@1.9.7) + '@tanstack/devtools-event-bus': 0.2.1 + '@tanstack/devtools-ui': 0.3.0(csstype@3.1.3)(solid-js@1.9.7) + clsx: 2.1.1 + goober: 2.1.16(csstype@3.1.3) + solid-js: 1.9.7 + transitivePeerDependencies: + - bufferutil + - csstype + - utf-8-validate + '@tanstack/directive-functions-plugin@1.129.7(vite@6.3.5(@types/node@22.17.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/code-frame': 7.27.1 @@ -9640,6 +9698,19 @@ snapshots: transitivePeerDependencies: - typescript + '@tanstack/react-devtools@0.3.0(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(csstype@3.1.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(solid-js@1.9.7)': + dependencies: + '@tanstack/devtools': 0.3.1(csstype@3.1.3)(solid-js@1.9.7) + '@types/react': 19.1.9 + '@types/react-dom': 19.1.7(@types/react@19.1.9) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + transitivePeerDependencies: + - bufferutil + - csstype + - solid-js + - utf-8-validate + '@tanstack/react-query@5.83.0(react@19.1.1)': dependencies: '@tanstack/query-core': 5.83.0 From 3fbabca4c88d62a9c3451807189174dd0b8489f9 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Wed, 13 Aug 2025 17:05:38 +0100 Subject: [PATCH 03/18] fix lint --- packages/db-devtools/eslint.config.js | 33 -------- packages/db-devtools/package.json | 2 +- .../db-devtools/src/TanstackDbDevtools.tsx | 2 +- packages/db-devtools/src/registry.ts | 2 +- packages/react-db-devtools/package.json | 2 +- .../src/ReactDbDevtoolsPanel.tsx | 82 ++++++++++--------- 6 files changed, 49 insertions(+), 74 deletions(-) delete mode 100644 packages/db-devtools/eslint.config.js diff --git a/packages/db-devtools/eslint.config.js b/packages/db-devtools/eslint.config.js deleted file mode 100644 index 0c7505c86..000000000 --- a/packages/db-devtools/eslint.config.js +++ /dev/null @@ -1,33 +0,0 @@ -import prettierPlugin from "eslint-plugin-prettier" -import prettierConfig from "eslint-config-prettier" -import stylisticPlugin from "@stylistic/eslint-plugin" -import { tanstackConfig } from "@tanstack/config/eslint" - -export default [ - ...tanstackConfig, - { ignores: [`dist/`, 'build/**', 'coverage/**', 'eslint.config.js'] }, - { - plugins: { - stylistic: stylisticPlugin, - prettier: prettierPlugin, - }, - rules: { - "prettier/prettier": `error`, - "stylistic/quotes": [`error`, `backtick`], - ...prettierConfig.rules, - "no-console": "warn", - "@typescript-eslint/no-unused-vars": [ - `error`, - { argsIgnorePattern: `^_`, varsIgnorePattern: `^_` }, - ], - "@typescript-eslint/naming-convention": [ - "error", - { - selector: "typeParameter", - format: ["PascalCase"], - leadingUnderscore: `allow`, - }, - ], - }, - }, -] \ No newline at end of file diff --git a/packages/db-devtools/package.json b/packages/db-devtools/package.json index c04c67c58..0f5d1e95b 100644 --- a/packages/db-devtools/package.json +++ b/packages/db-devtools/package.json @@ -90,4 +90,4 @@ "vite-plugin-dts": "^4.5.4", "vite-plugin-solid": "^2.11.6" } -} \ No newline at end of file +} diff --git a/packages/db-devtools/src/TanstackDbDevtools.tsx b/packages/db-devtools/src/TanstackDbDevtools.tsx index 1565b5356..8bd47ed29 100644 --- a/packages/db-devtools/src/TanstackDbDevtools.tsx +++ b/packages/db-devtools/src/TanstackDbDevtools.tsx @@ -41,7 +41,7 @@ class TanstackDbDevtools { // Only initialize on the client side if (typeof window === `undefined`) { - throw new Error('TanstackDbDevtools cannot be instantiated during SSR') + throw new Error(`TanstackDbDevtools cannot be instantiated during SSR`) } this.#registry = initializeDevtoolsRegistry() diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index d49343ba4..b12ffb361 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -348,7 +348,7 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { // Create dummy signals that won't be used during SSR const dummySignal = () => [] dummySignal.set = () => {} - + return { collections: new Map(), collectionsSignal: dummySignal as any, diff --git a/packages/react-db-devtools/package.json b/packages/react-db-devtools/package.json index a72919a32..7984acf6d 100644 --- a/packages/react-db-devtools/package.json +++ b/packages/react-db-devtools/package.json @@ -95,4 +95,4 @@ "@tanstack/react-db": "workspace:^", "react": "^18 || ^19" } -} \ No newline at end of file +} diff --git a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx index aa7e42891..11a1f6e5b 100644 --- a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx +++ b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx @@ -1,12 +1,12 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from "react" export interface ReactDbDevtoolsPanelOptions { // Additional React-specific props if needed } -export const TanStackReactDbDevtoolsPanel: React.FC = ( - props, -): React.ReactElement | null => { +export const TanStackReactDbDevtoolsPanel: React.FC< + ReactDbDevtoolsPanelOptions +> = (props): React.ReactElement | null => { const { ...rest } = props // SSR safety check - return null during SSR @@ -28,19 +28,23 @@ export const TanStackReactDbDevtoolsPanel: React.FC try { // Dynamically import the devtools to avoid SSR issues const { getDevtoolsRegistry } = await import(`@tanstack/db-devtools`) - + // Get the existing registry (should already be initialized) const existingRegistry = getDevtoolsRegistry() - + if (!existingRegistry) { - throw new Error('DB devtools registry not found. Make sure initializeDbDevtools() was called.') + throw new Error( + `DB devtools registry not found. Make sure initializeDbDevtools() was called.` + ) } - + setRegistry(existingRegistry) setIsInitialized(true) - } catch (error) { - console.error('Failed to initialize DB devtools:', error) - setError(error instanceof Error ? error.message : 'Unknown error') + } catch (initError) { + console.error(`Failed to initialize DB devtools:`, initError) + setError( + initError instanceof Error ? initError.message : `Unknown error` + ) } } @@ -53,9 +57,11 @@ export const TanStackReactDbDevtoolsPanel: React.FC async function mountSolidComponent() { try { // Import SolidJS render and the base panel component - const { render } = await import('solid-js/web') - const { BaseTanStackDbDevtoolsPanel } = await import('@tanstack/db-devtools') - + const { render } = await import(`solid-js/web`) + const { BaseTanStackDbDevtoolsPanel } = await import( + `@tanstack/db-devtools` + ) + // Clean up any existing component if (solidRootRef.current) { solidRootRef.current() @@ -67,22 +73,24 @@ export const TanStackReactDbDevtoolsPanel: React.FC return BaseTanStackDbDevtoolsPanel({ registry: () => registry, style: () => ({ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - overflow: 'hidden' + height: `100%`, + width: `100%`, + display: `flex`, + flexDirection: `column`, + overflow: `hidden`, }), - ...rest + ...rest, }) } // Render the SolidJS component into the container const dispose = render(SolidComponent, containerRef.current!) solidRootRef.current = dispose - } catch (error) { - console.error('Failed to mount SolidJS component:', error) - setError(error instanceof Error ? error.message : 'Unknown error') + } catch (mountError) { + console.error(`Failed to mount SolidJS component:`, mountError) + setError( + mountError instanceof Error ? mountError.message : `Unknown error` + ) } } @@ -93,8 +101,8 @@ export const TanStackReactDbDevtoolsPanel: React.FC if (solidRootRef.current) { try { solidRootRef.current() - } catch (error) { - console.error('Error disposing SolidJS component:', error) + } catch (disposeError) { + console.error(`Error disposing SolidJS component:`, disposeError) } solidRootRef.current = null } @@ -109,7 +117,7 @@ export const TanStackReactDbDevtoolsPanel: React.FC // Show error state if initialization failed if (error) { return ( -
+

Failed to load DB Devtools

Error: {error}

@@ -119,7 +127,7 @@ export const TanStackReactDbDevtoolsPanel: React.FC // Show loading state while initializing if (!isInitialized || !registry) { return ( -
+

Loading DB Devtools...

) @@ -128,15 +136,15 @@ export const TanStackReactDbDevtoolsPanel: React.FC // Render a container div that the SolidJS component will be mounted into // Use flexbox to ensure it takes up the full available height return ( -
) -} \ No newline at end of file +} From 2b78035fe113b3ce60e1df71e10a8d15a285e64a Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 08:54:48 +0100 Subject: [PATCH 04/18] checkpoint --- packages/db-devtools/package.json | 4 + .../src/BaseTanStackDbDevtoolsPanel.tsx | 221 +++++++++-- .../src/components/CollectionDetailsPanel.tsx | 18 +- packages/db-devtools/src/devtools-store.ts | 361 ++++++++++++++++++ packages/db-devtools/src/devtools.ts | 81 ++-- packages/db-devtools/src/global-types.ts | 37 ++ packages/db-devtools/src/index.ts | 24 +- packages/db-devtools/src/registry.ts | 285 +++----------- packages/db-devtools/src/types.ts | 61 +++ packages/db/src/collection.ts | 48 ++- packages/db/src/local-only.ts | 6 + packages/db/src/proxy.ts | 32 +- .../db/src/query/live-query-collection.ts | 8 + packages/db/src/types.ts | 5 + .../src/ReactDbDevtoolsPanel.tsx | 4 +- pnpm-lock.yaml | 7 + 16 files changed, 847 insertions(+), 355 deletions(-) create mode 100644 packages/db-devtools/src/devtools-store.ts create mode 100644 packages/db-devtools/src/global-types.ts diff --git a/packages/db-devtools/package.json b/packages/db-devtools/package.json index 0f5d1e95b..c8b8988bd 100644 --- a/packages/db-devtools/package.json +++ b/packages/db-devtools/package.json @@ -74,6 +74,10 @@ "build": "vite build", "build:dev": "tsup --watch" }, + "dependencies": { + "@tanstack/db": "workspace:*", + "@tanstack/solid-db": "workspace:*" + }, "devDependencies": { "@kobalte/core": "^0.13.4", "@solid-primitives/keyed": "^1.2.2", diff --git a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx index 2ddc6ba9b..3cf2b7c76 100644 --- a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx +++ b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx @@ -1,5 +1,7 @@ import { clsx as cx } from "clsx" import { Show, createEffect, createMemo, createSignal } from "solid-js" +import { useLiveQuery } from "@tanstack/solid-db" +import { createLiveQueryCollection, createCollection, localOnlyCollectionOptions } from "@tanstack/db" import { useDevtoolsOnClose } from "./contexts" import { useStyles } from "./useStyles" import { useLocalStorage } from "./useLocalStorage" @@ -13,9 +15,7 @@ import { } from "./components" import type { Accessor, JSX } from "solid-js" import type { - CollectionMetadata, DbDevtoolsRegistry, - TransactionDetails, } from "./types" export interface BaseDbDevtoolsPanelOptions { @@ -70,53 +70,185 @@ export const BaseTanStackDbDevtoolsPanel = const [selectedTransaction, setSelectedTransaction] = createSignal< string | null >(null) - const [collections, setCollections] = createSignal< - Array - >([]) - const [transactions, setTransactions] = createSignal< - Array - >([]) + + // Reactive views derived from live queries + // We'll compute these as memos that create fresh arrays so Solid tracks per-update + + // Use useLiveQuery for reactive data from devtools collections + // Wrap in try-catch to prevent crashes if collections are not properly initialized + let collectionsQuery: any + let transactionsQuery: any + let collectionsLQ: any + let transactionsLQ: any + // Local-only empty placeholders for early-render fallbacks + let emptyCollectionsCol: any + let emptyTransactionsCol: any + + try { + // Precreate a live query collection for collections, mark as devtools internal to avoid self-registration + collectionsQuery = useLiveQuery(() => { + const reg = registry() + if (!reg || !reg.store || !reg.store.collections) { + if (!emptyCollectionsCol) { + emptyCollectionsCol = createCollection( + localOnlyCollectionOptions({ + id: `__devtools_empty_collections`, + __devtoolsInternal: true, + getKey: (entry: any) => entry.id ?? Math.random().toString(36), + }) + ) + } + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_collections_empty`, + query: (q: any) => q.from({ collections: emptyCollectionsCol }), + startSync: true, + gcTime: 5000, + } as any) + } + if (!collectionsLQ) { + collectionsLQ = createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_collections`, + startSync: true, + gcTime: 5000, + query: (q: any) => + q + .from({ collections: reg.store.collections }) + .select(({ collections }: any) => ({ + id: collections.id, + type: collections.metadata.type, + status: collections.metadata.status, + size: collections.metadata.size, + hasTransactions: collections.metadata.hasTransactions, + transactionCount: collections.metadata.transactionCount, + createdAt: collections.metadata.createdAt, + lastUpdated: collections.metadata.lastUpdated, + gcTime: collections.metadata.gcTime, + timings: collections.metadata.timings, + })), + } as any) + } + return collectionsLQ + }) + + transactionsQuery = useLiveQuery(() => { + const reg = registry() + if (!reg || !reg.store || !reg.store.transactions) { + if (!emptyTransactionsCol) { + emptyTransactionsCol = createCollection( + localOnlyCollectionOptions({ + id: `__devtools_empty_transactions`, + __devtoolsInternal: true, + getKey: (entry: any) => entry.id ?? Math.random().toString(36), + }) + ) + } + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_transactions_empty`, + query: (q: any) => q.from({ transactions: emptyTransactionsCol }), + startSync: true, + gcTime: 5000, + } as any) + } + if (!transactionsLQ) { + transactionsLQ = createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_transactions`, + startSync: true, + gcTime: 5000, + query: (q: any) => + q + .from({ transactions: reg.store.transactions }) + .select(({ transactions }: any) => ({ + id: transactions.id, + collectionId: transactions.collectionId, + state: transactions.state, + mutations: transactions.mutations, + createdAt: transactions.createdAt, + updatedAt: transactions.updatedAt, + isPersisted: transactions.isPersisted, + })), + } as any) + } + return transactionsLQ + }) + + // No explicit effects needed; we'll read from queries directly in memos below + } catch (error) { + console.error('Error initializing useLiveQuery:', error) + collectionsQuery = { data: [] as any[] } + transactionsQuery = { data: [] as any[] } + } + + // Reactive arrays derived from live queries (copy to trigger tracking) + const collectionsArray = createMemo(() => + Array.isArray(collectionsQuery.data) + ? (collectionsQuery.data as Array).slice() + : [] + ) + const transactions = createMemo(() => { + const raw = Array.isArray(transactionsQuery.data) + ? (transactionsQuery.data as Array).slice() + : [] + return raw.map((entry: any) => ({ + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + isPersisted: entry.isPersisted, + })) + }) // Computed values const activeCollection = createMemo(() => { - const found = collections().find((c) => c.id === activeCollectionId()) - return found + try { + const data = collectionsArray() + if (!data || !Array.isArray(data)) { + return undefined + } + const found = data.find((c: any) => c.id === activeCollectionId()) + return found + } catch (error) { + console.error('Error in activeCollection memo:', error) + return undefined + } }) const activeTransaction = createMemo(() => { - const found = transactions().find((t) => t.id === selectedTransaction()) - return found + try { + const transactionsData = transactions() + if (!transactionsData || !Array.isArray(transactionsData)) return undefined + const found = transactionsData.find((t: any) => t.id === selectedTransaction()) + return found + } catch (error) { + console.error('Error in activeTransaction memo:', error) + return undefined + } }) - // Use reactive signals for immediate updates + // Use reactive data for immediate updates createEffect(() => { try { // Get collections from reactive signal - const newCollections = registry().collectionsSignal() + const newCollections = collectionsArray() + if (!newCollections || !Array.isArray(newCollections)) { + return + } // Simple auto-selection: if no collection is selected and we have collections, select the first one if (activeCollectionId() === `` && newCollections.length > 0) { setActiveCollectionId(newCollections[0]?.id ?? ``) } - - // Update collections state - setCollections(newCollections) - } catch { - // console.error(`Error updating collections:`, error) + } catch (error) { + console.error('Error updating collections:', error) } }) - createEffect(() => { - try { - // Get transactions from reactive signal - const newTransactions = registry().transactionsSignal() - - // Update transactions state - setTransactions(newTransactions) - } catch { - // console.error(`Error updating transactions:`, error) - } - }) + // Note: Transactions are handled reactively through useLiveQuery return (
collections().length} - transactionsCount={() => transactions().length} + collectionsCount={() => collectionsArray().length} + transactionsCount={() => { + try { + return transactions().length + } catch (error) { + console.error('Error getting transactions count:', error) + return 0 + } + }} onSelectView={setSelectedView} />
@@ -176,7 +315,7 @@ export const BaseTanStackDbDevtoolsPanel =
setActiveCollectionId(c.id)} /> @@ -184,7 +323,15 @@ export const BaseTanStackDbDevtoolsPanel = { + try { + if (typeof transactions !== 'function') return [] + return transactions() + } catch (error) { + console.error('Error getting transactions for panel:', error) + return [] + } + }} selectedTransaction={selectedTransaction} onSelectTransaction={setSelectedTransaction} /> @@ -195,13 +342,13 @@ export const BaseTanStackDbDevtoolsPanel =
- +
diff --git a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx index d8ac93e7c..d7626c0a0 100644 --- a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx +++ b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createSignal } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { clsx as cx } from "clsx" import { useStyles } from "../useStyles" import { getDevtoolsRegistry } from "../devtools" @@ -33,6 +33,20 @@ export function CollectionDetailsPanel({ return { metadata, instance: collectionInstance } }) + // Bump a reactive tick when the underlying collection emits changes + const [stateVersion, setStateVersion] = createSignal(0) + createEffect(() => { + const current = collection() + if (!current || !current.instance) return + const unsubscribe = current.instance.subscribeChanges(() => { + // Any change to the collection should retrigger the state view + setStateVersion((v) => v + 1) + }) + onCleanup(() => { + unsubscribe?.() + }) + }) + const collectionTransactions = createMemo(() => { const metadata = activeCollection() if (!metadata || !registry) return [] @@ -94,6 +108,8 @@ export function CollectionDetailsPanel({ ) } + // Depend on stateVersion so updates re-render this block + stateVersion() const stateData = { syncedData: instance.syncedData, optimisticUpserts: instance.optimisticUpserts, diff --git a/packages/db-devtools/src/devtools-store.ts b/packages/db-devtools/src/devtools-store.ts new file mode 100644 index 000000000..2909a5eb6 --- /dev/null +++ b/packages/db-devtools/src/devtools-store.ts @@ -0,0 +1,361 @@ +import { createCollection, localOnlyCollectionOptions } from "@tanstack/db" +import type { CollectionImpl } from "../../db/src/collection" +import type { Transaction } from "../../db/src/transactions" +import type { + DevtoolsCollectionEntry, + DevtoolsTransactionEntry, + DevtoolsStore, + CollectionMetadata +} from "./types" + +// Collections collection - stores devtools collection entries +const devtoolsCollectionsCollection = createCollection( + localOnlyCollectionOptions({ + id: '__devtools_collections', + __devtoolsInternal: true, // Prevent self-registration + getKey: (entry: DevtoolsCollectionEntry) => entry.id, + }) +) + +// Transactions collection - stores devtools transaction entries +const devtoolsTransactionsCollection = createCollection( + localOnlyCollectionOptions({ + id: '__devtools_transactions', + __devtoolsInternal: true, // Prevent self-registration + getKey: (entry: DevtoolsTransactionEntry) => entry.id, + }) +) + +class DevtoolsStoreImpl implements DevtoolsStore { + public collections = devtoolsCollectionsCollection as any + public transactions = devtoolsTransactionsCollection as any + + registerCollection = (collection: CollectionImpl): (() => void) | undefined => { + // Check if collection is already registered + const existingEntry = this.collections.get(collection.id) + if (existingEntry) { + // Collection already exists, just update the weak ref and return existing callback + existingEntry.weakRef = new WeakRef(collection) + return existingEntry.updateCallback + } + + const metadata: CollectionMetadata = { + id: collection.id, + type: this.detectCollectionType(collection), + status: collection.status, + size: collection.size, + hasTransactions: collection.transactions.size > 0, + transactionCount: collection.transactions.size, + createdAt: new Date(), + lastUpdated: new Date(), + gcTime: collection.config.gcTime, + timings: this.isLiveQuery(collection) + ? { + totalIncrementalRuns: 0, + } + : undefined, + } + + // Create a callback that updates metadata for this specific collection + const updateCallback = () => { + this.updateCollection(collection.id) + } + + // Create a callback that updates only transactions for this collection + const updateTransactionsCallback = () => { + this.updateTransactions(collection.id) + } + + const entry: DevtoolsCollectionEntry = { + id: collection.id, + weakRef: new WeakRef(collection), + metadata, + isActive: false, + updateCallback, + updateTransactionsCallback, + } + + // Insert into collections collection + this.collections.insert(entry) + + // Track performance for live queries + if (this.isLiveQuery(collection)) { + this.instrumentLiveQuery(collection, entry) + } + + // Call the update callback immediately so devtools UI updates right away + queueMicrotask(updateCallback) + + // Return the update callback for the collection to use + return updateCallback + } + + unregisterCollection = (id: string): void => { + const entry = this.collections.get(id) + if (entry) { + // Release any hard reference + entry.hardRef = undefined + entry.isActive = false + this.collections.delete(id) + } + } + + registerTransaction = (transaction: Transaction, collectionId: string): void => { + // Check if transaction is already registered + const existingEntry = this.transactions.get(transaction.id) + if (existingEntry) { + // Transaction already exists, just update the weak ref and state + existingEntry.weakRef = new WeakRef(transaction) + existingEntry.state = transaction.state + existingEntry.isPersisted = transaction.state === `completed` + existingEntry.updatedAt = new Date() + return + } + + const entry: DevtoolsTransactionEntry = { + id: transaction.id, + collectionId, + state: transaction.state, + mutations: transaction.mutations.map((m: any) => ({ + id: m.mutationId, + type: m.type, + key: m.key, + optimistic: m.optimistic, + createdAt: m.createdAt, + original: m.original, + modified: m.modified, + changes: m.changes, + })), + createdAt: transaction.createdAt, + updatedAt: transaction.createdAt, + isPersisted: transaction.state === `completed`, + weakRef: new WeakRef(transaction), + } + + // Insert into transactions collection + this.transactions.insert(entry) + // Also bump the parent collection metadata to reflect transaction counts immediately + const parent = this.collections.get(collectionId) + if (parent) { + this.updateCollection(collectionId) + } + } + + getCollection = (id: string): CollectionImpl | undefined => { + const entry = this.collections.get(id) + if (!entry) return undefined + + const collection = entry.weakRef.deref() + if (collection && !entry.isActive) { + // Create hard reference + entry.hardRef = collection + entry.isActive = true + } + + return collection + } + + releaseCollection = (id: string): void => { + const entry = this.collections.get(id) + if (entry && entry.isActive) { + // Release hard reference + entry.hardRef = undefined + entry.isActive = false + } + } + + getAllCollectionMetadata = (): Array => { + const results: Array = [] + + for (const entry of this.collections.values()) { + const collection = entry.weakRef.deref() + if (collection) { + // Compute fresh metadata snapshot without mutating stored entry in-place + const snapshot: CollectionMetadata = { + ...entry.metadata, + status: collection.status, + size: collection.size, + hasTransactions: collection.transactions.size > 0, + transactionCount: collection.transactions.size, + lastUpdated: new Date(), + } + results.push(snapshot) + } else { + // Collection was garbage collected, report cleaned-up snapshot (do not mutate entry) + const snapshot: CollectionMetadata = { + ...entry.metadata, + status: `cleaned-up`, + lastUpdated: new Date(), + } + results.push(snapshot) + } + } + + return results + } + + getTransactions = (collectionId?: string): Array => { + const transactions: Array = [] + + for (const entry of this.transactions.values()) { + if (collectionId && entry.collectionId !== collectionId) continue + + // Update transaction state from weak ref if available + const transaction = entry.weakRef.deref() + if (transaction) { + entry.state = transaction.state + entry.isPersisted = transaction.state === `completed` + entry.updatedAt = new Date() + } + + transactions.push({ ...entry }) + } + + return transactions.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + ) + } + + updateCollection = (id: string): void => { + const entry = this.collections.get(id) + if (!entry) return + + const collection = entry.weakRef.deref() + if (collection) { + // Build fresh metadata snapshot (avoid mutating stored entry to ensure change detection) + const newMetadata: CollectionMetadata = { + ...entry.metadata, + status: collection.status, + size: collection.size, + hasTransactions: collection.transactions.size > 0, + transactionCount: collection.transactions.size, + lastUpdated: new Date(), + } + + this.collections.update(id, (draft: any) => { + draft.metadata = newMetadata + }) + } + } + + updateTransactions = (collectionId?: string): void => { + // Update all transactions for the collection + for (const entry of this.transactions.values()) { + if (collectionId && entry.collectionId !== collectionId) continue + + const transaction = entry.weakRef.deref() + if (transaction) { + const newState = transaction.state + const newPersisted = transaction.state === `completed` + const newUpdatedAt = new Date() + + this.transactions.update(entry.id, (draft: any) => { + draft.state = newState + draft.isPersisted = newPersisted + draft.updatedAt = newUpdatedAt + }) + } + } + } + + cleanup = (): void => { + // Release all hard references + for (const entry of this.collections.values()) { + if (entry.isActive) { + entry.hardRef = undefined + entry.isActive = false + } + } + } + + garbageCollect = (): void => { + // Remove entries for collections that have been garbage collected + const collectionsToRemove: string[] = [] + for (const entry of this.collections.values()) { + const collection = entry.weakRef.deref() + if (!collection) { + collectionsToRemove.push(entry.id) + } + } + + // Remove dead collections + for (const id of collectionsToRemove) { + this.collections.delete(id) + } + + // Remove entries for transactions that have been garbage collected + const transactionsToRemove: string[] = [] + for (const entry of this.transactions.values()) { + const transaction = entry.weakRef.deref() + if (!transaction) { + transactionsToRemove.push(entry.id) + } + } + + // Remove dead transactions + for (const id of transactionsToRemove) { + this.transactions.delete(id) + } + } + + private detectCollectionType = (collection: any): string => { + // Check the new collection type marker first + if (collection.config.collectionType) { + return collection.config.collectionType + } + + // Default to generic collection + return `generic` + } + + private isLiveQuery = (collection: any): boolean => { + return this.detectCollectionType(collection) === `live-query` + } + + private instrumentLiveQuery = ( + collection: any, + entry: DevtoolsCollectionEntry + ): void => { + // This is where we would add performance tracking for live queries + // We'll need to hook into the query execution pipeline to track timings + // For now, this is a placeholder + if (!entry.metadata.timings) { + entry.metadata.timings = { + totalIncrementalRuns: 0, + } + } + } +} + +// Create and export the devtools store +export function createDevtoolsStore(): DevtoolsStore { + return new DevtoolsStoreImpl() as any +} + +// Initialize the global devtools store +export function initializeDevtoolsStore(): DevtoolsStore { + // SSR safety check - return a no-op store for server-side rendering + if (typeof window === `undefined`) { + return { + collections: {} as any, + transactions: {} as any, + registerCollection: () => undefined, + unregisterCollection: () => {}, + registerTransaction: () => {}, + getCollection: () => undefined, + releaseCollection: () => {}, + getAllCollectionMetadata: () => [], + getTransactions: () => [], + updateCollection: () => {}, + updateTransactions: () => {}, + cleanup: () => {}, + garbageCollect: () => {}, + } as DevtoolsStore + } + + // Only create real store on the client side + if (!(window as any).__TANSTACK_DB_DEVTOOLS_STORE__) { + ;(window as any).__TANSTACK_DB_DEVTOOLS_STORE__ = createDevtoolsStore() + } + return (window as any).__TANSTACK_DB_DEVTOOLS_STORE__ as DevtoolsStore +} diff --git a/packages/db-devtools/src/devtools.ts b/packages/db-devtools/src/devtools.ts index 56dba3c84..0abd83127 100644 --- a/packages/db-devtools/src/devtools.ts +++ b/packages/db-devtools/src/devtools.ts @@ -1,4 +1,6 @@ import { initializeDevtoolsRegistry } from "./registry" +import { initializeDevtoolsStore } from "./devtools-store" +import { getDevtools } from "./global-types" import type { CollectionImpl } from "../../db/src/collection" import type { DbDevtoolsRegistry } from "./types" @@ -14,28 +16,18 @@ export function initializeDbDevtools(): void { } // Check if devtools are already initialized - if ((window as any).__TANSTACK_DB_DEVTOOLS__) { + if (getDevtools()) { return } - // Initialize the registry + // Initialize the registry and store const registry = initializeDevtoolsRegistry() + const store = initializeDevtoolsStore() - // Store the registry globally under the namespaced structure + // Store the registry globally with proper typing ;(window as any).__TANSTACK_DB_DEVTOOLS__ = { ...registry, - collectionsSignal: registry.collectionsSignal, - transactionsSignal: registry.transactionsSignal, - registerCollection: (collection: any) => { - const updateCallback = registry.registerCollection(collection) - // Store the callback on the collection for later use - if (updateCallback && collection) { - collection.__devtoolsUpdateCallback = updateCallback - } - }, - unregisterCollection: (id: string) => { - registry.unregisterCollection(id) - }, + store: store, } } @@ -48,14 +40,12 @@ export function registerCollection( ): void { if (typeof window === `undefined`) return - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ as { - registerCollection: (collection: any) => (() => void) | undefined - } - const updateCallback: (() => void) | undefined = - devtools.registerCollection(collection) - // Store the callback on the collection for later use - if (updateCallback && collection) { - ;(collection as any).__devtoolsUpdateCallback = updateCallback + const devtools = getDevtools() + if (devtools?.registerCollection && collection) { + const updateCallback = devtools.registerCollection(collection) + if (updateCallback && collection) { + ;(collection as any).__devtoolsUpdateCallback = updateCallback + } } } @@ -66,10 +56,8 @@ export function registerCollection( export function unregisterCollection(id: string): void { if (typeof window === `undefined`) return - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ as { - unregisterCollection: (id: string) => void - } - devtools.unregisterCollection(id) + const devtools = getDevtools() + devtools?.unregisterCollection(id) } /** @@ -77,35 +65,16 @@ export function unregisterCollection(id: string): void { */ export function isDevtoolsEnabled(): boolean { if (typeof window === `undefined`) return false - return !!(window as any).__TANSTACK_DB_DEVTOOLS__ + return !!getDevtools() } export function getDevtoolsRegistry(): DbDevtoolsRegistry | undefined { if (typeof window === `undefined`) return undefined - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__! - - // Return the registry part of the devtools object - return { - collections: devtools.collections, - collectionsSignal: devtools.collectionsSignal, - transactionsSignal: devtools.transactionsSignal, - registerCollection: devtools.registerCollection, - unregisterCollection: devtools.unregisterCollection, - getCollection: devtools.getCollection, - releaseCollection: devtools.releaseCollection, - getAllCollectionMetadata: devtools.getAllCollectionMetadata, - getCollectionMetadata: devtools.getCollectionMetadata, - updateCollectionMetadata: devtools.updateCollectionMetadata, - updateTransactions: devtools.updateTransactions, - getTransactions: devtools.getTransactions, - getTransaction: devtools.getTransaction, - getTransactionDetails: devtools.getTransactionDetails, - clearTransactionHistory: devtools.clearTransactionHistory, - onTransactionStart: devtools.onTransactionStart, - onTransactionEnd: devtools.onTransactionEnd, - cleanup: devtools.cleanup, - garbageCollect: devtools.garbageCollect, - } as DbDevtoolsRegistry + const devtools = getDevtools() + if (!devtools) return undefined + + // Return the actual registry instance that has the store property + return devtools as unknown as DbDevtoolsRegistry } /** @@ -132,10 +101,8 @@ export function triggerTransactionUpdate( ): void { if (typeof window === `undefined`) return - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ - if (devtools?.updateTransactions) { - devtools.updateTransactions(collection.id) - } + const devtools = getDevtools() + devtools?.updateTransactions(collection.id) } /** @@ -145,7 +112,7 @@ export function triggerTransactionUpdate( export function cleanupDevtools(): void { if (typeof window === `undefined`) return - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + const devtools = getDevtools() if (devtools?.cleanup) { devtools.cleanup() delete (window as any).__TANSTACK_DB_DEVTOOLS__ diff --git a/packages/db-devtools/src/global-types.ts b/packages/db-devtools/src/global-types.ts new file mode 100644 index 000000000..0c7f26094 --- /dev/null +++ b/packages/db-devtools/src/global-types.ts @@ -0,0 +1,37 @@ +import type { CollectionImpl } from "../../db/src/collection" +import type { Transaction } from "../../db/src/transactions" +import type { CollectionMetadata, TransactionDetails, DevtoolsStore } from "./types" + +// Global type definitions for devtools +declare global { + interface Window { + __TANSTACK_DB_DEVTOOLS__?: { + registerCollection: ( + collection: CollectionImpl + ) => (() => void) | undefined + unregisterCollection: (id: string) => void + registerTransaction: ( + transaction: Transaction, + collectionId: string + ) => void + updateCollection: (id: string) => void + updateTransactions: (collectionId?: string) => void + getCollection: (id: string) => CollectionImpl | undefined + releaseCollection: (id: string) => void + getAllCollectionMetadata: () => Array + getTransactions: (collectionId?: string) => Array + cleanup: () => void + garbageCollect: () => void + store: DevtoolsStore + } + } +} + +// Export the type for use in other files +export type TanStackDbDevtools = NonNullable + +// Helper function for accessing devtools with proper typing +export function getDevtools(): TanStackDbDevtools | undefined { + if (typeof window === `undefined`) return undefined + return window.__TANSTACK_DB_DEVTOOLS__ +} diff --git a/packages/db-devtools/src/index.ts b/packages/db-devtools/src/index.ts index 4b28a2f21..8aee0b830 100644 --- a/packages/db-devtools/src/index.ts +++ b/packages/db-devtools/src/index.ts @@ -1,16 +1,16 @@ -// Core exports -export * from "./types" -export * from "./constants" +// Re-export all public APIs export * from "./devtools" +export * from "./devtools-store" +export * from "./global-types" export * from "./registry" -export * from "./BaseTanStackDbDevtoolsPanel" - -// Components -export { Explorer } from "./components/Explorer" +export * from "./types" -// Main Devtools Class (follows TanStack pattern) -export { TanstackDbDevtools } from "./TanstackDbDevtools" -export type { TanstackDbDevtoolsConfig } from "./TanstackDbDevtools" +// Re-export components +export { BaseTanStackDbDevtoolsPanel } from "./BaseTanStackDbDevtoolsPanel" +export { TanstackDbDevtools, type TanstackDbDevtoolsConfig } from "./TanstackDbDevtools" +export { FloatingTanStackDbDevtools } from "./FloatingTanStackDbDevtools" -// Export the initialization function -export { initializeDbDevtools } from "./devtools" +// Re-export utilities +export { useLocalStorage } from "./useLocalStorage" +export { useStyles } from "./useStyles" +export { useDevtoolsOnClose } from "./contexts" diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index b12ffb361..57d5fb5e5 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -1,18 +1,24 @@ import { createSignal } from "solid-js" import type { CollectionMetadata, - CollectionRegistryEntry, DbDevtoolsRegistry, TransactionDetails, } from "./types" +import { initializeDevtoolsStore } from "./devtools-store" +import { getDevtools } from "./global-types" class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { - public collections = new Map() + public store = initializeDevtoolsStore() - // SolidJS signals for reactive updates + // SolidJS signals for reactive updates (kept for backward compatibility) private _collectionsSignal = createSignal>([]) private _transactionsSignal = createSignal>([]) + // Expose collections map for backward compatibility + public get collections() { + return new Map() // Empty map since we're using collections now + } + constructor() { // No polling needed; updates are now immediate via signals } @@ -27,9 +33,9 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { } private triggerUpdate = () => { - // Update collections signal + // Update collections signal with a fresh array reference const collectionsData = this.getAllCollectionMetadata() - this._collectionsSignal[1](collectionsData) + this._collectionsSignal[1]([...collectionsData]) // Update transactions signal const transactionsData = this.getTransactions() @@ -37,22 +43,11 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { } private triggerCollectionUpdate = (id: string) => { - // Get the current collections array + const updatedMetadata = this.getCollectionMetadata(id) + if (!updatedMetadata) return const currentCollections = this._collectionsSignal[0]() - - // Find the index of the collection to update - const index = currentCollections.findIndex((c) => c.id === id) - - if (index !== -1) { - // Get updated metadata for this specific collection - const updatedMetadata = this.getCollectionMetadata(id) - if (updatedMetadata) { - // Create a new array with the updated collection - const newCollections = [...currentCollections] - newCollections[index] = updatedMetadata - this._collectionsSignal[1](newCollections) - } - } + const next = currentCollections.map((c) => (c.id === id ? updatedMetadata : c)) + this._collectionsSignal[1](next) } private triggerTransactionUpdate = (collectionId?: string) => { @@ -62,52 +57,13 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { } registerCollection = (collection: any): (() => void) | undefined => { - const metadata: CollectionMetadata = { - id: collection.id, - type: this.detectCollectionType(collection), - status: collection.status, - size: collection.size, - hasTransactions: collection.transactions.size > 0, - transactionCount: collection.transactions.size, - createdAt: new Date(), - lastUpdated: new Date(), - gcTime: collection.config.gcTime, - timings: this.isLiveQuery(collection) - ? { - totalIncrementalRuns: 0, - } - : undefined, - } - - // Create a callback that updates metadata for this specific collection - // This callback doesn't hold strong references to the collection - const updateCallback = () => { - this.updateCollectionMetadata(collection.id) - } - - // Create a callback that updates only transactions for this collection - const updateTransactionsCallback = () => { - this.triggerTransactionUpdate(collection.id) - } - - const entry: CollectionRegistryEntry = { - weakRef: new WeakRef(collection), - metadata, - isActive: false, - updateCallback, - updateTransactionsCallback, - } - - this.collections.set(collection.id, entry) - - // Track performance for live queries - if (this.isLiveQuery(collection)) { - this.instrumentLiveQuery(collection, entry) + const updateCallback = this.store.registerCollection(collection) + + // Set the update callback on the collection for future updates + if (updateCallback && collection) { + ;(collection as any).__devtoolsUpdateCallback = updateCallback } - - // Call the update callback immediately so devtools UI updates right away - updateCallback() - + // Trigger reactive update for immediate UI refresh this.triggerUpdate() @@ -116,20 +72,14 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { } unregisterCollection = (id: string): void => { - const entry = this.collections.get(id) - if (entry) { - // Release any hard reference - entry.hardRef = undefined - entry.isActive = false - this.collections.delete(id) - } + this.store.unregisterCollection(id) // Trigger reactive update for immediate UI refresh this.triggerUpdate() } getCollectionMetadata = (id: string): CollectionMetadata | undefined => { - const entry = this.collections.get(id) + const entry = this.store.collections.get(id) if (!entry) return undefined // Try to get fresh data from the collection if it's still alive @@ -147,42 +97,11 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { } getAllCollectionMetadata = (): Array => { - const results: Array = [] - - for (const [_id, entry] of this.collections) { - const collection = entry.weakRef.deref() - if (collection) { - // Collection is still alive, update metadata - entry.metadata.status = collection.status - entry.metadata.size = collection.size - entry.metadata.hasTransactions = collection.transactions.size > 0 - entry.metadata.transactionCount = collection.transactions.size - entry.metadata.lastUpdated = new Date() - results.push({ ...entry.metadata }) - } else { - // Collection was garbage collected, mark it - entry.metadata.status = `cleaned-up` - entry.metadata.lastUpdated = new Date() - results.push({ ...entry.metadata }) - } - } - - return results + return this.store.getAllCollectionMetadata() } updateCollectionMetadata = (id: string): void => { - const entry = this.collections.get(id) - if (!entry) return - - const collection = entry.weakRef.deref() - if (collection) { - // Update metadata with fresh data from the collection - entry.metadata.status = collection.status - entry.metadata.size = collection.size - entry.metadata.hasTransactions = collection.transactions.size > 0 - entry.metadata.transactionCount = collection.transactions.size - entry.metadata.lastUpdated = new Date() - } + this.store.updateCollection(id) // Use efficient update that only changes the specific collection this.triggerCollectionUpdate(id) @@ -192,147 +111,54 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { } updateTransactions = (collectionId?: string): void => { + this.store.updateTransactions(collectionId) this.triggerTransactionUpdate(collectionId) } getCollection = (id: string): any => { - const entry = this.collections.get(id) - if (!entry) return undefined - - const collection = entry.weakRef.deref() - if (collection && !entry.isActive) { - // Create hard reference - entry.hardRef = collection - entry.isActive = true - } - - return collection + return this.store.getCollection(id) } releaseCollection = (id: string): void => { - const entry = this.collections.get(id) - if (entry && entry.isActive) { - // Release hard reference - entry.hardRef = undefined - entry.isActive = false - } + this.store.releaseCollection(id) } getTransactions = (collectionId?: string): Array => { - const transactions: Array = [] - - for (const [_id, entry] of this.collections) { - if (collectionId && _id !== collectionId) continue - - const collection = entry.weakRef.deref() - if (!collection) continue - - for (const [txId, transaction] of collection.transactions) { - transactions.push({ - id: txId, - collectionId: _id, - state: transaction.state, - mutations: transaction.mutations.map((m: any) => ({ - id: m.mutationId, - type: m.type, - key: m.key, - optimistic: m.optimistic, - createdAt: m.createdAt, - original: m.original, - modified: m.modified, - changes: m.changes, - })), - createdAt: transaction.createdAt, - updatedAt: transaction.createdAt, // Transaction doesn't have updatedAt, using createdAt - isPersisted: transaction.state === `completed`, - }) - } - } - - return transactions.sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() - ) + const devtoolsTransactions = this.store.getTransactions(collectionId) + + // Convert DevtoolsTransactionEntry to TransactionDetails for backward compatibility + return devtoolsTransactions.map(entry => ({ + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + isPersisted: entry.isPersisted, + })) } getTransaction = (id: string): TransactionDetails | undefined => { - for (const [_collectionId, entry] of this.collections) { - const collection = entry.weakRef.deref() - if (!collection) continue - - const transaction = collection.transactions.get(id) - if (transaction) { - return { - id, - collectionId: _collectionId, - state: transaction.state, - mutations: transaction.mutations.map((m: any) => ({ - id: m.mutationId, - type: m.type, - key: m.key, - optimistic: m.optimistic, - createdAt: m.createdAt, - original: m.original, - modified: m.modified, - changes: m.changes, - })), - createdAt: transaction.createdAt, - updatedAt: transaction.createdAt, // Transaction doesn't have updatedAt, using createdAt - isPersisted: transaction.state === `completed`, - } - } + const entry = this.store.transactions.get(id) + if (!entry) return undefined + + return { + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + isPersisted: entry.isPersisted, } - return undefined } cleanup = (): void => { - // Stop polling - // No polling to stop - - // Release all hard references - for (const [_id, entry] of this.collections) { - if (entry.isActive) { - entry.hardRef = undefined - entry.isActive = false - } - } + this.store.cleanup() } garbageCollect = (): void => { - // Remove entries for collections that have been garbage collected - for (const [id, entry] of this.collections) { - const collection = entry.weakRef.deref() - if (!collection) { - this.collections.delete(id) - } - } - } - - private detectCollectionType = (collection: any): string => { - // Check the new collection type marker first - if (collection.config.collectionType) { - return collection.config.collectionType - } - - // Default to generic collection - return `generic` - } - - private isLiveQuery = (collection: any): boolean => { - return this.detectCollectionType(collection) === `live-query` - } - - private instrumentLiveQuery = ( - collection: any, - entry: CollectionRegistryEntry - ): void => { - // This is where we would add performance tracking for live queries - // We'll need to hook into the query execution pipeline to track timings - // For now, this is a placeholder - if (!entry.metadata.timings) { - entry.metadata.timings = { - totalIncrementalRuns: 0, - } - } + this.store.garbageCollect() } } @@ -351,6 +177,7 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { return { collections: new Map(), + store: initializeDevtoolsStore(), collectionsSignal: dummySignal as any, transactionsSignal: dummySignal as any, registerCollection: () => undefined, @@ -373,8 +200,8 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { } // Only create real signals on the client side - if (!(window as any).__TANSTACK_DB_DEVTOOLS__) { + if (!getDevtools()) { ;(window as any).__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() } - return (window as any).__TANSTACK_DB_DEVTOOLS__ as DbDevtoolsRegistry + return (getDevtools() as unknown) as DbDevtoolsRegistry } diff --git a/packages/db-devtools/src/types.ts b/packages/db-devtools/src/types.ts index 34eaeaf99..938710437 100644 --- a/packages/db-devtools/src/types.ts +++ b/packages/db-devtools/src/types.ts @@ -1,5 +1,6 @@ import type { CollectionImpl } from "../../db/src/collection" import type { CollectionStatus } from "../../db/src/types" +import type { Collection } from "../../db/src/collection" export interface DbDevtoolsConfig { /** @@ -70,6 +71,63 @@ export interface CollectionMetadata { syncConfig?: any } +export interface DevtoolsCollectionEntry { + id: string + weakRef: WeakRef> + metadata: CollectionMetadata + isActive: boolean // Whether we're currently viewing this collection (hard ref held) + hardRef?: CollectionImpl // Only set when actively viewing + updateCallback?: () => void // Callback to trigger metadata update (doesn't hold strong refs) + updateTransactionsCallback?: () => void // Callback to trigger transaction update only (doesn't hold strong refs) +} + +export interface DevtoolsTransactionEntry { + id: string + collectionId: string + state: string + mutations: Array<{ + id: string + type: `insert` | `update` | `delete` + key: any + optimistic: boolean + createdAt: Date + original?: any + modified?: any + changes?: any + }> + createdAt: Date + updatedAt: Date + isPersisted: boolean + // Keep reference to actual transaction for real-time updates + weakRef: WeakRef // Transaction +} + +export interface DevtoolsStore { + collections: Collection + transactions: Collection + + // Registration methods + registerCollection: (collection: CollectionImpl) => (() => void) | undefined + unregisterCollection: (id: string) => void + registerTransaction: (transaction: any, collectionId: string) => void + + // Access methods + getCollection: (id: string) => CollectionImpl | undefined + releaseCollection: (id: string) => void + + // Metadata access + getAllCollectionMetadata: () => Array + getTransactions: (collectionId?: string) => Array + + // Update methods + updateCollection: (id: string) => void + updateTransactions: (collectionId?: string) => void + + // Utility methods + cleanup: () => void + garbageCollect: () => void +} + export interface CollectionRegistryEntry { weakRef: WeakRef> metadata: CollectionMetadata @@ -101,6 +159,9 @@ export interface TransactionDetails { export interface DbDevtoolsRegistry { collections: Map + // Store for the new local-only collections implementation + store: DevtoolsStore + // SolidJS signals for reactive UI updates collectionsSignal: () => Array transactionsSignal: () => Array diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 24b9f6cac..bd531d322 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -64,10 +64,26 @@ import type { BaseIndex, IndexResolver } from "./indexes/base-index.js" // Check for devtools registry and register collection if available function registerWithDevtools(collection: CollectionImpl): void { + // Skip registration if this is a devtools internal collection + if (collection.config.__devtoolsInternal) { + return + } + + // Skip if already registered + if ((collection as any).isRegisteredWithDevtools) { + return + } + if (typeof window !== `undefined`) { - if ((window as any).__TANSTACK_DB_DEVTOOLS__?.registerCollection) { - ;(window as any).__TANSTACK_DB_DEVTOOLS__.registerCollection(collection) - ;(collection as any).isRegisteredWithDevtools = true + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + if (devtools?.registerCollection) { + const updateCallback = devtools.registerCollection(collection) + if (updateCallback && collection) { + ;(collection as any).__devtoolsUpdateCallback = updateCallback + ;(collection as any).isRegisteredWithDevtools = true + } else { + ;(collection as any).isRegisteredWithDevtools = false + } } else { ;(collection as any).isRegisteredWithDevtools = false } @@ -86,17 +102,8 @@ function triggerDevtoolsUpdate( } } -// Declare the devtools registry on window -declare global { - interface Window { - __TANSTACK_DB_DEVTOOLS__?: { - registerCollection: ( - collection: CollectionImpl - ) => (() => void) | undefined - unregisterCollection: (id: string) => void - } - } -} +// Import global devtools types (will be available when devtools are loaded) +// The actual types are declared in @tanstack/db-devtools interface PendingSyncedTransaction> { committed: boolean @@ -1418,6 +1425,19 @@ export class CollectionImpl< * @private */ private scheduleTransactionCleanup(transaction: Transaction): void { + // Skip cleanup for devtools internal collections + if (this.config.__devtoolsInternal) { + return + } + + // Register transaction with devtools (don't delete from devtools) + if (typeof window !== `undefined`) { + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + if (devtools?.registerTransaction) { + devtools.registerTransaction(transaction, this.id) + } + } + // Only schedule cleanup for transactions that aren't already completed if (transaction.state === `completed`) { this.transactions.delete(transaction.id) diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index 431fca02d..b37bf15de 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -38,6 +38,12 @@ export interface LocalOnlyCollectionConfig< schema?: TSchema getKey: (item: ResolveType) => TKey + /** + * Internal flag to prevent devtools registration for devtools-owned collections + * @internal + */ + __devtoolsInternal?: boolean + /** * Optional initial data to populate the collection with on creation * This data will be applied during the initial sync process diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 2c0050b1a..f93e9d865 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -159,7 +159,12 @@ function deepClone( /** * Deep equality check that handles special types like Date, RegExp, Map, and Set */ -function deepEqual(a: T, b: T): boolean { +function deepEqual( + a: T, + b: T, + // Track visited object pairs to avoid infinite recursion on cyclic structures + visited: WeakMap> = new WeakMap() +): boolean { // Handle primitive types if (a === b) return true @@ -173,6 +178,25 @@ function deepEqual(a: T, b: T): boolean { return false } + // Before descending into object comparisons, guard against cycles by + // memoizing object pairs that have already been compared + const objA = a as unknown as object + const objB = b as unknown as object + const isObjA = typeof a === `object` && a !== null + const isObjB = typeof b === `object` && b !== null + + if (isObjA && isObjB) { + const seenForA = visited.get(objA) + if (seenForA && seenForA.has(objB)) { + return true + } + const setForA = seenForA ?? new Set() + setForA.add(objB) + if (!seenForA) { + visited.set(objA, setForA) + } + } + // Handle Date objects if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime() @@ -189,7 +213,7 @@ function deepEqual(a: T, b: T): boolean { const entries = Array.from(a.entries()) for (const [key, val] of entries) { - if (!b.has(key) || !deepEqual(val, b.get(key))) { + if (!b.has(key) || !deepEqual(val, b.get(key), visited)) { return false } } @@ -220,7 +244,7 @@ function deepEqual(a: T, b: T): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { - if (!deepEqual(a[i], b[i])) return false + if (!deepEqual(a[i], b[i], visited)) return false } return true @@ -253,7 +277,7 @@ function deepEqual(a: T, b: T): boolean { return keysA.every( (key) => Object.prototype.hasOwnProperty.call(b, key) && - deepEqual((a as any)[key], (b as any)[key]) + deepEqual((a as any)[key], (b as any)[key], visited) ) } diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 2c8957d87..9785417c8 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -88,6 +88,12 @@ export interface LiveQueryCollectionConfig< * GC time for the collection */ gcTime?: number + + /** + * Marks this live query as internal to devtools so it won't register itself + * with the devtools registry (prevents circular references in the UI) + */ + __devtoolsInternal?: boolean } /** @@ -385,6 +391,8 @@ export function liveQueryCollectionOptions< startSync: config.startSync, // Mark as live query for devtools collectionType: `live-query` as const, + // Propagate devtools-internal marker to avoid self-registration + __devtoolsInternal: config.__devtoolsInternal, } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6ab46519b..843ecf185 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -402,6 +402,11 @@ export interface CollectionConfig< * @internal */ collectionType?: string + /** + * Internal flag to prevent devtools registration for devtools-owned collections + * @internal + */ + __devtoolsInternal?: boolean /** * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information diff --git a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx index 11a1f6e5b..967b56f4a 100644 --- a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx +++ b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx @@ -70,6 +70,8 @@ export const TanStackReactDbDevtoolsPanel: React.FC< // Create a SolidJS component that renders the base panel const SolidComponent = () => { + // Filter out React-specific props that might not be compatible + const { children, ...solidProps } = rest as any return BaseTanStackDbDevtoolsPanel({ registry: () => registry, style: () => ({ @@ -79,7 +81,7 @@ export const TanStackReactDbDevtoolsPanel: React.FC< flexDirection: `column`, overflow: `hidden`, }), - ...rest, + ...solidProps, }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea888cb7..cea649c48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -500,6 +500,13 @@ importers: version: 2.1.20 packages/db-devtools: + dependencies: + '@tanstack/db': + specifier: workspace:* + version: link:../db + '@tanstack/solid-db': + specifier: workspace:* + version: link:../solid-db devDependencies: '@kobalte/core': specifier: ^0.13.4 From 1a19019b8656b1c480dbc307899f3be9fbdfb049 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 11:34:09 +0100 Subject: [PATCH 05/18] checkpint --- .../src/components/TransactionItem.tsx | 6 ++- .../src/components/TransactionsPanel.tsx | 2 +- packages/db-devtools/src/devtools-store.ts | 12 +++++ packages/db-devtools/src/devtools.ts | 26 +++++++++++ packages/db-devtools/src/useStyles.tsx | 10 +++++ packages/db/src/collection.ts | 44 +++++++++++++++++-- 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/db-devtools/src/components/TransactionItem.tsx b/packages/db-devtools/src/components/TransactionItem.tsx index 0eb0ad31e..182bd4f58 100644 --- a/packages/db-devtools/src/components/TransactionItem.tsx +++ b/packages/db-devtools/src/components/TransactionItem.tsx @@ -1,11 +1,12 @@ import { clsx as cx } from "clsx" import { useStyles } from "../useStyles" import { TransactionStats } from "./TransactionStats" +import type { Accessor } from "solid-js" import type { TransactionDetails } from "../types" interface TransactionItemProps { transaction: TransactionDetails - isActive: boolean + isActive: Accessor onSelect: (transactionId: string) => void } @@ -20,10 +21,11 @@ export function TransactionItem({
onSelect(transaction.id)} > + {isActive() ?
: null}
{transaction.id}
diff --git a/packages/db-devtools/src/components/TransactionsPanel.tsx b/packages/db-devtools/src/components/TransactionsPanel.tsx index 8fe20fcd2..11b09421e 100644 --- a/packages/db-devtools/src/components/TransactionsPanel.tsx +++ b/packages/db-devtools/src/components/TransactionsPanel.tsx @@ -45,7 +45,7 @@ export function TransactionsPanel({ {(transaction) => ( selectedTransaction() === transaction.id} onSelect={onSelectTransaction} /> )} diff --git a/packages/db-devtools/src/devtools-store.ts b/packages/db-devtools/src/devtools-store.ts index 2909a5eb6..cd65f0e9a 100644 --- a/packages/db-devtools/src/devtools-store.ts +++ b/packages/db-devtools/src/devtools-store.ts @@ -134,6 +134,12 @@ class DevtoolsStoreImpl implements DevtoolsStore { // Insert into transactions collection this.transactions.insert(entry) + console.debug(`[devtools] transaction inserted`, { + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations.length, + }) // Also bump the parent collection metadata to reflect transaction counts immediately const parent = this.collections.get(collectionId) if (parent) { @@ -254,6 +260,12 @@ class DevtoolsStoreImpl implements DevtoolsStore { draft.isPersisted = newPersisted draft.updatedAt = newUpdatedAt }) + + // Optional: when a transaction completes, ensure parent metadata updates + if (newPersisted) { + const parent = this.collections.get(entry.collectionId) + if (parent) this.updateCollection(entry.collectionId) + } } } } diff --git a/packages/db-devtools/src/devtools.ts b/packages/db-devtools/src/devtools.ts index 0abd83127..f2c473c0e 100644 --- a/packages/db-devtools/src/devtools.ts +++ b/packages/db-devtools/src/devtools.ts @@ -28,6 +28,32 @@ export function initializeDbDevtools(): void { ;(window as any).__TANSTACK_DB_DEVTOOLS__ = { ...registry, store: store, + // Expose helpers for debugging + _debug: { + logTransactions: () => + console.debug(`[devtools] transactions`, Array.from(store.transactions.values())), + logCollections: () => + console.debug(`[devtools] collections`, Array.from(store.collections.values())), + }, + } + + // Flush any transactions that were queued before devtools initialized + const w: any = window as any + const pending = w.__TANSTACK_DB_PENDING_TRANSACTIONS__ as + | Array<{ transaction: any; collectionId: string }> + | undefined + if (Array.isArray(pending) && pending.length) { + try { + for (const { transaction, collectionId } of pending) { + store.registerTransaction(transaction, collectionId) + console.log(`[devtools] flushed pending transaction`, { + id: transaction.id, + collectionId, + }) + } + } finally { + w.__TANSTACK_DB_PENDING_TRANSACTIONS__ = [] + } } } diff --git a/packages/db-devtools/src/useStyles.tsx b/packages/db-devtools/src/useStyles.tsx index 1746672ed..180e2c800 100644 --- a/packages/db-devtools/src/useStyles.tsx +++ b/packages/db-devtools/src/useStyles.tsx @@ -295,6 +295,7 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => { cursor: pointer; background-color: ${colors.darkGray[700]}; transition: all 0.2s ease; + position: relative; &:hover { background-color: ${colors.darkGray[600]}; @@ -304,6 +305,15 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => { background-color: ${colors.darkGray[600]}; border-left: 3px solid ${colors.blue[500]}; `, + activeIndicator: css` + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background-color: ${colors.blue[500]}; + pointer-events: none; + `, collectionName: css` font-weight: ${font.weight.medium}; color: ${colors.gray[200]}; diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index bd531d322..6d621a36f 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -1432,9 +1432,39 @@ export class CollectionImpl< // Register transaction with devtools (don't delete from devtools) if (typeof window !== `undefined`) { - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ - if (devtools?.registerTransaction) { - devtools.registerTransaction(transaction, this.id) + const w: any = window as any + const devtools = w.__TANSTACK_DB_DEVTOOLS__ + try { + if (devtools?.store?.registerTransaction) { + devtools.store.registerTransaction(transaction, this.id) + console.log(`[db->devtools] registerTransaction`, { + collectionId: this.id, + txId: transaction.id, + state: transaction.state, + }) + } else if (devtools?.registerTransaction) { + // Back-compat if global exposes method at root + devtools.registerTransaction(transaction, this.id) + console.log(`[db->devtools] registerTransaction (legacy)`, { + collectionId: this.id, + txId: transaction.id, + state: transaction.state, + }) + } else { + // Devtools not ready yet – queue for later flush + w.__TANSTACK_DB_PENDING_TRANSACTIONS__ = + w.__TANSTACK_DB_PENDING_TRANSACTIONS__ || [] + w.__TANSTACK_DB_PENDING_TRANSACTIONS__.push({ + transaction, + collectionId: this.id, + }) + console.log(`[db->devtools] queued transaction`, { + collectionId: this.id, + txId: transaction.id, + }) + } + } catch (e) { + console.warn(`[db->devtools] registerTransaction failed`, e) } } @@ -2455,5 +2485,13 @@ export class CollectionImpl< // Trigger devtools update after transaction state changes triggerDevtoolsUpdate(this) + + // Also trigger transaction list/state refresh in devtools + if (typeof window !== `undefined`) { + const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + try { + devtools?.updateTransactions?.(this.id) + } catch {} + } } } From fbd21168af1db54976dd713bcd544238661e9494 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 11:58:45 +0100 Subject: [PATCH 06/18] checkpint --- .../src/BaseTanStackDbDevtoolsPanel.tsx | 183 +++++++++++++++--- .../src/components/CollectionDetailsPanel.tsx | 60 +++++- 2 files changed, 210 insertions(+), 33 deletions(-) diff --git a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx index 3cf2b7c76..bba8110ce 100644 --- a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx +++ b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx @@ -1,7 +1,7 @@ import { clsx as cx } from "clsx" import { Show, createEffect, createMemo, createSignal } from "solid-js" import { useLiveQuery } from "@tanstack/solid-db" -import { createLiveQueryCollection, createCollection, localOnlyCollectionOptions } from "@tanstack/db" +import { createLiveQueryCollection, createCollection, localOnlyCollectionOptions, eq } from "@tanstack/db" import { useDevtoolsOnClose } from "./contexts" import { useStyles } from "./useStyles" import { useLocalStorage } from "./useLocalStorage" @@ -80,24 +80,38 @@ export const BaseTanStackDbDevtoolsPanel = let transactionsQuery: any let collectionsLQ: any let transactionsLQ: any + let selectedCollectionLQ: any + let transactionsForCollectionLQ: any + let selectedTransactionLQ: any // Local-only empty placeholders for early-render fallbacks let emptyCollectionsCol: any let emptyTransactionsCol: any try { + // Ensure empty placeholder collections exist for any fallback paths + if (!emptyCollectionsCol) { + emptyCollectionsCol = createCollection( + localOnlyCollectionOptions({ + id: `__devtools_empty_collections`, + __devtoolsInternal: true, + getKey: (entry: any) => entry.id ?? Math.random().toString(36), + }) + ) + } + if (!emptyTransactionsCol) { + emptyTransactionsCol = createCollection( + localOnlyCollectionOptions({ + id: `__devtools_empty_transactions`, + __devtoolsInternal: true, + getKey: (entry: any) => entry.id ?? Math.random().toString(36), + }) + ) + } + // Precreate a live query collection for collections, mark as devtools internal to avoid self-registration collectionsQuery = useLiveQuery(() => { const reg = registry() if (!reg || !reg.store || !reg.store.collections) { - if (!emptyCollectionsCol) { - emptyCollectionsCol = createCollection( - localOnlyCollectionOptions({ - id: `__devtools_empty_collections`, - __devtoolsInternal: true, - getKey: (entry: any) => entry.id ?? Math.random().toString(36), - }) - ) - } return createLiveQueryCollection({ __devtoolsInternal: true, id: `__devtools_view_collections_empty`, @@ -135,15 +149,6 @@ export const BaseTanStackDbDevtoolsPanel = transactionsQuery = useLiveQuery(() => { const reg = registry() if (!reg || !reg.store || !reg.store.transactions) { - if (!emptyTransactionsCol) { - emptyTransactionsCol = createCollection( - localOnlyCollectionOptions({ - id: `__devtools_empty_transactions`, - __devtoolsInternal: true, - getKey: (entry: any) => entry.id ?? Math.random().toString(36), - }) - ) - } return createLiveQueryCollection({ __devtoolsInternal: true, id: `__devtools_view_transactions_empty`, @@ -161,6 +166,7 @@ export const BaseTanStackDbDevtoolsPanel = query: (q: any) => q .from({ transactions: reg.store.transactions }) + .orderBy(({ transactions }: any) => transactions.createdAt, 'desc') .select(({ transactions }: any) => ({ id: transactions.id, collectionId: transactions.collectionId, @@ -175,6 +181,114 @@ export const BaseTanStackDbDevtoolsPanel = return transactionsLQ }) + // Selected collection via live query with where clause + selectedCollectionLQ = useLiveQuery(() => { + const reg = registry() + const id = activeCollectionId() + if (!reg || !reg.store || !reg.store.collections || !id) { + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_selected_collection_empty`, + startSync: true, + gcTime: 3000, + query: (q: any) => q.from({ collections: emptyCollectionsCol }), + } as any) + } + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_selected_collection_${id}`, + startSync: true, + gcTime: 5000, + query: (q: any) => + q + .from({ collections: reg.store.collections }) + .where(({ collections }: any) => eq(collections.id, id)) + .select(({ collections }: any) => ({ + id: collections.id, + type: collections.metadata.type, + status: collections.metadata.status, + size: collections.metadata.size, + hasTransactions: collections.metadata.hasTransactions, + transactionCount: collections.metadata.transactionCount, + createdAt: collections.metadata.createdAt, + lastUpdated: collections.metadata.lastUpdated, + gcTime: collections.metadata.gcTime, + timings: collections.metadata.timings, + })), + } as any) + }) + + // Transactions filtered by selected collection id + transactionsForCollectionLQ = useLiveQuery(() => { + const reg = registry() + const id = activeCollectionId() + if (!reg || !reg.store || !reg.store.transactions || !id) { + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_transactions_for_collection_empty`, + startSync: true, + gcTime: 3000, + query: (q: any) => q.from({ transactions: emptyTransactionsCol }), + } as any) + } + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_transactions_for_collection_${id}`, + startSync: true, + gcTime: 5000, + query: (q: any) => + q + .from({ transactions: reg.store.transactions }) + .where(({ transactions }: any) => + eq(transactions.collectionId, id) + ) + .orderBy(({ transactions }: any) => transactions.createdAt, 'desc') + .select(({ transactions }: any) => ({ + id: transactions.id, + collectionId: transactions.collectionId, + state: transactions.state, + mutations: transactions.mutations, + createdAt: transactions.createdAt, + updatedAt: transactions.updatedAt, + isPersisted: transactions.isPersisted, + })), + } as any) + }) + + // Selected transaction via live query with where by id + selectedTransactionLQ = useLiveQuery(() => { + const reg = registry() + const id = selectedTransaction() + if (!reg || !reg.store || !reg.store.transactions || !id) { + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_selected_transaction_empty`, + startSync: true, + gcTime: 3000, + query: (q: any) => q.from({ transactions: emptyTransactionsCol }), + } as any) + } + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_selected_transaction_${id}`, + startSync: true, + gcTime: 5000, + query: (q: any) => + q + .from({ transactions: reg.store.transactions }) + .where(({ transactions }: any) => eq(transactions.id, id)) + .select(({ transactions }: any) => ({ + id: transactions.id, + collectionId: transactions.collectionId, + state: transactions.state, + mutations: transactions.mutations, + createdAt: transactions.createdAt, + updatedAt: transactions.updatedAt, + isPersisted: transactions.isPersisted, + })), + } as any) + }) + // No explicit effects needed; we'll read from queries directly in memos below } catch (error) { console.error('Error initializing useLiveQuery:', error) @@ -203,6 +317,21 @@ export const BaseTanStackDbDevtoolsPanel = })) }) + const transactionsForActiveCollection = createMemo(() => { + const raw = Array.isArray(transactionsForCollectionLQ?.data) + ? ((transactionsForCollectionLQ.data as Array).slice()) + : [] + return raw.map((entry: any) => ({ + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + isPersisted: entry.isPersisted, + })) + }) + // Computed values const activeCollection = createMemo(() => { try { @@ -211,7 +340,7 @@ export const BaseTanStackDbDevtoolsPanel = return undefined } const found = data.find((c: any) => c.id === activeCollectionId()) - return found + return found } catch (error) { console.error('Error in activeCollection memo:', error) return undefined @@ -220,12 +349,11 @@ export const BaseTanStackDbDevtoolsPanel = const activeTransaction = createMemo(() => { try { - const transactionsData = transactions() - if (!transactionsData || !Array.isArray(transactionsData)) return undefined - const found = transactionsData.find((t: any) => t.id === selectedTransaction()) - return found + const data = selectedTransactionLQ?.data as any[] + if (!data || !Array.isArray(data)) return undefined + return data[0] } catch (error) { - console.error('Error in activeTransaction memo:', error) + console.error('Error in activeTransaction memo (selected LQ):', error) return undefined } }) @@ -325,8 +453,9 @@ export const BaseTanStackDbDevtoolsPanel = { try { - if (typeof transactions !== 'function') return [] - return transactions() + const id = activeCollectionId() + if (!id) return transactions() + return transactionsForActiveCollection() } catch (error) { console.error('Error getting transactions for panel:', error) return [] diff --git a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx index d7626c0a0..abdccba99 100644 --- a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx +++ b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx @@ -1,4 +1,6 @@ import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { useLiveQuery } from "@tanstack/solid-db" +import { createCollection, createLiveQueryCollection, localOnlyCollectionOptions, eq } from "@tanstack/db" import { clsx as cx } from "clsx" import { useStyles } from "../useStyles" import { getDevtoolsRegistry } from "../devtools" @@ -47,11 +49,57 @@ export function CollectionDetailsPanel({ }) }) - const collectionTransactions = createMemo(() => { + // Live query for transactions filtered to the active collection + let emptyTransactionsCol: any + const transactionsForCollectionQuery: any = useLiveQuery(() => { const metadata = activeCollection() - if (!metadata || !registry) return [] + if (!emptyTransactionsCol) { + emptyTransactionsCol = createCollection( + localOnlyCollectionOptions({ + id: `__devtools_empty_transactions_for_collection`, + __devtoolsInternal: true, + getKey: (entry: any) => entry.id ?? Math.random().toString(36), + }) + ) + } + + if (!registry || !registry.store || !registry.store.transactions || !metadata) { + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_transactions_for_collection_empty_local`, + startSync: true, + gcTime: 3000, + query: (q: any) => q.from({ transactions: emptyTransactionsCol }), + } as any) + } - return registry.getTransactions(metadata.id) + return createLiveQueryCollection({ + __devtoolsInternal: true, + id: `__devtools_view_transactions_for_collection_${metadata.id}`, + startSync: true, + gcTime: 5000, + query: (q: any) => + q + .from({ transactions: registry.store.transactions }) + .where(({ transactions }: any) => eq(transactions.collectionId, metadata.id)) + .orderBy(({ transactions }: any) => transactions.createdAt, 'desc') + .select(({ transactions }: any) => ({ + id: transactions.id, + collectionId: transactions.collectionId, + state: transactions.state, + mutations: transactions.mutations, + createdAt: transactions.createdAt, + updatedAt: transactions.updatedAt, + isPersisted: transactions.isPersisted, + })), + } as any) + }) + + const collectionTransactions = createMemo(() => { + const raw = Array.isArray(transactionsForCollectionQuery.data) + ? (transactionsForCollectionQuery.data as Array).slice() + : [] + return raw }) const activeTransaction = createMemo(() => { @@ -184,10 +232,10 @@ export function CollectionDetailsPanel({ )} > {tab.label} - {tab.id === `transactions` && - collectionTransactions().length > 0 && ( + {tab.id === `transactions` && Array.isArray(transactionsForCollectionQuery.data) && + (transactionsForCollectionQuery.data as any[]).length > 0 && ( - {collectionTransactions().length} + {(transactionsForCollectionQuery.data as any[]).length} )} From 4b7948b94c84a8fa824e0aa995717ecb3755a83e Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 12:03:44 +0100 Subject: [PATCH 07/18] checkpint --- packages/db-devtools/src/devtools-store.ts | 39 +++++++++++++--------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/db-devtools/src/devtools-store.ts b/packages/db-devtools/src/devtools-store.ts index cd65f0e9a..1f394a6b8 100644 --- a/packages/db-devtools/src/devtools-store.ts +++ b/packages/db-devtools/src/devtools-store.ts @@ -30,6 +30,21 @@ class DevtoolsStoreImpl implements DevtoolsStore { public collections = devtoolsCollectionsCollection as any public transactions = devtoolsTransactionsCollection as any + private countTransactionsForCollection = (collectionId: string): number => { + let count = 0 + for (const entry of this.transactions.values()) { + if (entry.collectionId === collectionId) count++ + } + return count + } + + private hasTransactionsForCollection = (collectionId: string): boolean => { + for (const entry of this.transactions.values()) { + if (entry.collectionId === collectionId) return true + } + return false + } + registerCollection = (collection: CollectionImpl): (() => void) | undefined => { // Check if collection is already registered const existingEntry = this.collections.get(collection.id) @@ -44,8 +59,8 @@ class DevtoolsStoreImpl implements DevtoolsStore { type: this.detectCollectionType(collection), status: collection.status, size: collection.size, - hasTransactions: collection.transactions.size > 0, - transactionCount: collection.transactions.size, + hasTransactions: this.hasTransactionsForCollection(collection.id), + transactionCount: this.countTransactionsForCollection(collection.id), createdAt: new Date(), lastUpdated: new Date(), gcTime: collection.config.gcTime, @@ -140,11 +155,8 @@ class DevtoolsStoreImpl implements DevtoolsStore { state: entry.state, mutations: entry.mutations.length, }) - // Also bump the parent collection metadata to reflect transaction counts immediately - const parent = this.collections.get(collectionId) - if (parent) { - this.updateCollection(collectionId) - } + // Bump the parent collection metadata to reflect transaction counts immediately + this.updateCollection(collectionId) } getCollection = (id: string): CollectionImpl | undefined => { @@ -181,8 +193,8 @@ class DevtoolsStoreImpl implements DevtoolsStore { ...entry.metadata, status: collection.status, size: collection.size, - hasTransactions: collection.transactions.size > 0, - transactionCount: collection.transactions.size, + hasTransactions: this.hasTransactionsForCollection(entry.id), + transactionCount: this.countTransactionsForCollection(entry.id), lastUpdated: new Date(), } results.push(snapshot) @@ -233,8 +245,8 @@ class DevtoolsStoreImpl implements DevtoolsStore { ...entry.metadata, status: collection.status, size: collection.size, - hasTransactions: collection.transactions.size > 0, - transactionCount: collection.transactions.size, + hasTransactions: this.hasTransactionsForCollection(id), + transactionCount: this.countTransactionsForCollection(id), lastUpdated: new Date(), } @@ -262,10 +274,7 @@ class DevtoolsStoreImpl implements DevtoolsStore { }) // Optional: when a transaction completes, ensure parent metadata updates - if (newPersisted) { - const parent = this.collections.get(entry.collectionId) - if (parent) this.updateCollection(entry.collectionId) - } + this.updateCollection(entry.collectionId) } } } From 34ef46671ad3c9ff0231d6ccbc888fdb2dfdb533 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 13:05:02 +0100 Subject: [PATCH 08/18] checkpint --- packages/db-devtools/src/devtools-store.ts | 6 ---- packages/db-devtools/src/devtools.ts | 34 +++++++--------------- packages/db-devtools/src/global-types.ts | 17 ++--------- packages/db-devtools/src/registry.ts | 22 ++++---------- packages/db/src/collection.ts | 10 +++---- 5 files changed, 24 insertions(+), 65 deletions(-) diff --git a/packages/db-devtools/src/devtools-store.ts b/packages/db-devtools/src/devtools-store.ts index 1f394a6b8..ad4e469e1 100644 --- a/packages/db-devtools/src/devtools-store.ts +++ b/packages/db-devtools/src/devtools-store.ts @@ -149,12 +149,6 @@ class DevtoolsStoreImpl implements DevtoolsStore { // Insert into transactions collection this.transactions.insert(entry) - console.debug(`[devtools] transaction inserted`, { - id: entry.id, - collectionId: entry.collectionId, - state: entry.state, - mutations: entry.mutations.length, - }) // Bump the parent collection metadata to reflect transaction counts immediately this.updateCollection(collectionId) } diff --git a/packages/db-devtools/src/devtools.ts b/packages/db-devtools/src/devtools.ts index f2c473c0e..1248e424f 100644 --- a/packages/db-devtools/src/devtools.ts +++ b/packages/db-devtools/src/devtools.ts @@ -25,16 +25,10 @@ export function initializeDbDevtools(): void { const store = initializeDevtoolsStore() // Store the registry globally with proper typing - ;(window as any).__TANSTACK_DB_DEVTOOLS__ = { + window.__TANSTACK_DB_DEVTOOLS__ = { ...registry, - store: store, - // Expose helpers for debugging - _debug: { - logTransactions: () => - console.debug(`[devtools] transactions`, Array.from(store.transactions.values())), - logCollections: () => - console.debug(`[devtools] collections`, Array.from(store.collections.values())), - }, + // Keep store available for registerTransaction from core package + store, } // Flush any transactions that were queued before devtools initialized @@ -43,17 +37,10 @@ export function initializeDbDevtools(): void { | Array<{ transaction: any; collectionId: string }> | undefined if (Array.isArray(pending) && pending.length) { - try { - for (const { transaction, collectionId } of pending) { - store.registerTransaction(transaction, collectionId) - console.log(`[devtools] flushed pending transaction`, { - id: transaction.id, - collectionId, - }) - } - } finally { - w.__TANSTACK_DB_PENDING_TRANSACTIONS__ = [] + for (const { transaction, collectionId } of pending) { + store.registerTransaction(transaction, collectionId) } + w.__TANSTACK_DB_PENDING_TRANSACTIONS__ = [] } } @@ -128,7 +115,8 @@ export function triggerTransactionUpdate( if (typeof window === `undefined`) return const devtools = getDevtools() - devtools?.updateTransactions(collection.id) + // Delegate to store/registry through the public API + devtools?.store.updateTransactions(collection.id) } /** @@ -139,8 +127,8 @@ export function cleanupDevtools(): void { if (typeof window === `undefined`) return const devtools = getDevtools() - if (devtools?.cleanup) { - devtools.cleanup() - delete (window as any).__TANSTACK_DB_DEVTOOLS__ + if (devtools) { + devtools.store.cleanup() + delete window.__TANSTACK_DB_DEVTOOLS__ } } diff --git a/packages/db-devtools/src/global-types.ts b/packages/db-devtools/src/global-types.ts index 0c7f26094..852321deb 100644 --- a/packages/db-devtools/src/global-types.ts +++ b/packages/db-devtools/src/global-types.ts @@ -1,27 +1,16 @@ import type { CollectionImpl } from "../../db/src/collection" -import type { Transaction } from "../../db/src/transactions" -import type { CollectionMetadata, TransactionDetails, DevtoolsStore } from "./types" +import type { DevtoolsStore } from "./types" // Global type definitions for devtools declare global { interface Window { __TANSTACK_DB_DEVTOOLS__?: { + // Minimal surface area exposed globally registerCollection: ( collection: CollectionImpl ) => (() => void) | undefined unregisterCollection: (id: string) => void - registerTransaction: ( - transaction: Transaction, - collectionId: string - ) => void - updateCollection: (id: string) => void - updateTransactions: (collectionId?: string) => void - getCollection: (id: string) => CollectionImpl | undefined - releaseCollection: (id: string) => void - getAllCollectionMetadata: () => Array - getTransactions: (collectionId?: string) => Array - cleanup: () => void - garbageCollect: () => void + // Core package may call this while devtools are initializing store: DevtoolsStore } } diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index 57d5fb5e5..b4655f56b 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -19,9 +19,7 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { return new Map() // Empty map since we're using collections now } - constructor() { - // No polling needed; updates are now immediate via signals - } + constructor() {} // Expose signals for reactive UI updates public get collectionsSignal() { @@ -81,19 +79,9 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { getCollectionMetadata = (id: string): CollectionMetadata | undefined => { const entry = this.store.collections.get(id) if (!entry) return undefined - - // Try to get fresh data from the collection if it's still alive - const collection = entry.weakRef.deref() - if (collection) { - // Update metadata with fresh data - entry.metadata.status = collection.status - entry.metadata.size = collection.size - entry.metadata.hasTransactions = collection.transactions.size > 0 - entry.metadata.transactionCount = collection.transactions.size - entry.metadata.lastUpdated = new Date() - } - - return { ...entry.metadata } + // Delegate to store snapshot logic to avoid mutating entry metadata here + const all = this.store.getAllCollectionMetadata() + return all.find((c) => c.id === id) } getAllCollectionMetadata = (): Array => { @@ -201,7 +189,7 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { // Only create real signals on the client side if (!getDevtools()) { - ;(window as any).__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() + window.__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() as any } return (getDevtools() as unknown) as DbDevtoolsRegistry } diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 6d621a36f..103fb661d 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -75,7 +75,7 @@ function registerWithDevtools(collection: CollectionImpl): void { } if (typeof window !== `undefined`) { - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + const devtools = window.__TANSTACK_DB_DEVTOOLS__ as any if (devtools?.registerCollection) { const updateCallback = devtools.registerCollection(collection) if (updateCallback && collection) { @@ -734,9 +734,9 @@ export class CollectionImpl< // Unregister from devtools if available if ( typeof window !== `undefined` && - (window as any).__TANSTACK_DB_DEVTOOLS__?.unregisterCollection + window.__TANSTACK_DB_DEVTOOLS__?.unregisterCollection ) { - ;(window as any).__TANSTACK_DB_DEVTOOLS__.unregisterCollection(this.id) + window.__TANSTACK_DB_DEVTOOLS__!.unregisterCollection(this.id) this.isRegisteredWithDevtools = false } @@ -1433,7 +1433,7 @@ export class CollectionImpl< // Register transaction with devtools (don't delete from devtools) if (typeof window !== `undefined`) { const w: any = window as any - const devtools = w.__TANSTACK_DB_DEVTOOLS__ + const devtools = window.__TANSTACK_DB_DEVTOOLS__ as any try { if (devtools?.store?.registerTransaction) { devtools.store.registerTransaction(transaction, this.id) @@ -2488,7 +2488,7 @@ export class CollectionImpl< // Also trigger transaction list/state refresh in devtools if (typeof window !== `undefined`) { - const devtools = (window as any).__TANSTACK_DB_DEVTOOLS__ + const devtools = window.__TANSTACK_DB_DEVTOOLS__ as any try { devtools?.updateTransactions?.(this.id) } catch {} From 3786e89d35adc11ecb5eaf09d82850be2af1cb34 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 13:24:17 +0100 Subject: [PATCH 09/18] checkpint --- .../src/BaseTanStackDbDevtoolsPanel.tsx | 149 ++++++------------ .../src/components/CollectionDetailsPanel.tsx | 40 +++-- packages/db-devtools/src/devtools-store.ts | 29 ++-- packages/db-devtools/src/devtools.ts | 10 +- packages/db-devtools/src/global-types.ts | 2 +- packages/db-devtools/src/index.ts | 5 +- packages/db-devtools/src/registry.ts | 20 +-- packages/db-devtools/src/types.ts | 17 +- packages/db/src/devtools-globals.d.ts | 25 +++ .../src/ReactDbDevtoolsPanel.tsx | 2 +- 10 files changed, 149 insertions(+), 150 deletions(-) create mode 100644 packages/db/src/devtools-globals.d.ts diff --git a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx index bba8110ce..96a1ff896 100644 --- a/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx +++ b/packages/db-devtools/src/BaseTanStackDbDevtoolsPanel.tsx @@ -1,7 +1,12 @@ import { clsx as cx } from "clsx" import { Show, createEffect, createMemo, createSignal } from "solid-js" import { useLiveQuery } from "@tanstack/solid-db" -import { createLiveQueryCollection, createCollection, localOnlyCollectionOptions, eq } from "@tanstack/db" +import { + createCollection, + createLiveQueryCollection, + eq, + localOnlyCollectionOptions, +} from "@tanstack/db" import { useDevtoolsOnClose } from "./contexts" import { useStyles } from "./useStyles" import { useLocalStorage } from "./useLocalStorage" @@ -14,9 +19,7 @@ import { TransactionsPanel, } from "./components" import type { Accessor, JSX } from "solid-js" -import type { - DbDevtoolsRegistry, -} from "./types" +import type { DbDevtoolsRegistry } from "./types" export interface BaseDbDevtoolsPanelOptions { /** @@ -80,13 +83,14 @@ export const BaseTanStackDbDevtoolsPanel = let transactionsQuery: any let collectionsLQ: any let transactionsLQ: any - let selectedCollectionLQ: any + // Note: selectedCollectionLQ is currently unused, kept for potential future detail views + let transactionsForCollectionLQ: any let selectedTransactionLQ: any // Local-only empty placeholders for early-render fallbacks let emptyCollectionsCol: any let emptyTransactionsCol: any - + try { // Ensure empty placeholder collections exist for any fallback paths if (!emptyCollectionsCol) { @@ -108,18 +112,9 @@ export const BaseTanStackDbDevtoolsPanel = ) } - // Precreate a live query collection for collections, mark as devtools internal to avoid self-registration + // Live collections query collectionsQuery = useLiveQuery(() => { const reg = registry() - if (!reg || !reg.store || !reg.store.collections) { - return createLiveQueryCollection({ - __devtoolsInternal: true, - id: `__devtools_view_collections_empty`, - query: (q: any) => q.from({ collections: emptyCollectionsCol }), - startSync: true, - gcTime: 5000, - } as any) - } if (!collectionsLQ) { collectionsLQ = createLiveQueryCollection({ __devtoolsInternal: true, @@ -148,15 +143,6 @@ export const BaseTanStackDbDevtoolsPanel = transactionsQuery = useLiveQuery(() => { const reg = registry() - if (!reg || !reg.store || !reg.store.transactions) { - return createLiveQueryCollection({ - __devtoolsInternal: true, - id: `__devtools_view_transactions_empty`, - query: (q: any) => q.from({ transactions: emptyTransactionsCol }), - startSync: true, - gcTime: 5000, - } as any) - } if (!transactionsLQ) { transactionsLQ = createLiveQueryCollection({ __devtoolsInternal: true, @@ -166,7 +152,10 @@ export const BaseTanStackDbDevtoolsPanel = query: (q: any) => q .from({ transactions: reg.store.transactions }) - .orderBy(({ transactions }: any) => transactions.createdAt, 'desc') + .orderBy( + ({ transactions }: any) => transactions.createdAt, + `desc` + ) .select(({ transactions }: any) => ({ id: transactions.id, collectionId: transactions.collectionId, @@ -181,48 +170,13 @@ export const BaseTanStackDbDevtoolsPanel = return transactionsLQ }) - // Selected collection via live query with where clause - selectedCollectionLQ = useLiveQuery(() => { - const reg = registry() - const id = activeCollectionId() - if (!reg || !reg.store || !reg.store.collections || !id) { - return createLiveQueryCollection({ - __devtoolsInternal: true, - id: `__devtools_view_selected_collection_empty`, - startSync: true, - gcTime: 3000, - query: (q: any) => q.from({ collections: emptyCollectionsCol }), - } as any) - } - return createLiveQueryCollection({ - __devtoolsInternal: true, - id: `__devtools_view_selected_collection_${id}`, - startSync: true, - gcTime: 5000, - query: (q: any) => - q - .from({ collections: reg.store.collections }) - .where(({ collections }: any) => eq(collections.id, id)) - .select(({ collections }: any) => ({ - id: collections.id, - type: collections.metadata.type, - status: collections.metadata.status, - size: collections.metadata.size, - hasTransactions: collections.metadata.hasTransactions, - transactionCount: collections.metadata.transactionCount, - createdAt: collections.metadata.createdAt, - lastUpdated: collections.metadata.lastUpdated, - gcTime: collections.metadata.gcTime, - timings: collections.metadata.timings, - })), - } as any) - }) + // Selected collection live query not required for current UI; rely on collectionsArray // Transactions filtered by selected collection id transactionsForCollectionLQ = useLiveQuery(() => { const reg = registry() const id = activeCollectionId() - if (!reg || !reg.store || !reg.store.transactions || !id) { + if (!id) { return createLiveQueryCollection({ __devtoolsInternal: true, id: `__devtools_view_transactions_for_collection_empty`, @@ -242,7 +196,10 @@ export const BaseTanStackDbDevtoolsPanel = .where(({ transactions }: any) => eq(transactions.collectionId, id) ) - .orderBy(({ transactions }: any) => transactions.createdAt, 'desc') + .orderBy( + ({ transactions }: any) => transactions.createdAt, + `desc` + ) .select(({ transactions }: any) => ({ id: transactions.id, collectionId: transactions.collectionId, @@ -259,7 +216,7 @@ export const BaseTanStackDbDevtoolsPanel = selectedTransactionLQ = useLiveQuery(() => { const reg = registry() const id = selectedTransaction() - if (!reg || !reg.store || !reg.store.transactions || !id) { + if (!id) { return createLiveQueryCollection({ __devtoolsInternal: true, id: `__devtools_view_selected_transaction_empty`, @@ -291,9 +248,9 @@ export const BaseTanStackDbDevtoolsPanel = // No explicit effects needed; we'll read from queries directly in memos below } catch (error) { - console.error('Error initializing useLiveQuery:', error) - collectionsQuery = { data: [] as any[] } - transactionsQuery = { data: [] as any[] } + console.error(`Error initializing useLiveQuery:`, error) + collectionsQuery = { data: [] as Array } + transactionsQuery = { data: [] as Array } } // Reactive arrays derived from live queries (copy to trigger tracking) @@ -319,7 +276,7 @@ export const BaseTanStackDbDevtoolsPanel = const transactionsForActiveCollection = createMemo(() => { const raw = Array.isArray(transactionsForCollectionLQ?.data) - ? ((transactionsForCollectionLQ.data as Array).slice()) + ? (transactionsForCollectionLQ.data as Array).slice() : [] return raw.map((entry: any) => ({ id: entry.id, @@ -334,45 +291,24 @@ export const BaseTanStackDbDevtoolsPanel = // Computed values const activeCollection = createMemo(() => { - try { - const data = collectionsArray() - if (!data || !Array.isArray(data)) { - return undefined - } - const found = data.find((c: any) => c.id === activeCollectionId()) - return found - } catch (error) { - console.error('Error in activeCollection memo:', error) - return undefined - } + const data = collectionsArray() + const id = activeCollectionId() + if (!Array.isArray(data)) return undefined + return data.find((c: any) => c.id === id) }) const activeTransaction = createMemo(() => { - try { - const data = selectedTransactionLQ?.data as any[] - if (!data || !Array.isArray(data)) return undefined - return data[0] - } catch (error) { - console.error('Error in activeTransaction memo (selected LQ):', error) - return undefined - } + const data = selectedTransactionLQ?.data as Array + if (!Array.isArray(data)) return undefined + return data[0] }) // Use reactive data for immediate updates createEffect(() => { - try { - // Get collections from reactive signal - const newCollections = collectionsArray() - if (!newCollections || !Array.isArray(newCollections)) { - return - } - - // Simple auto-selection: if no collection is selected and we have collections, select the first one - if (activeCollectionId() === `` && newCollections.length > 0) { - setActiveCollectionId(newCollections[0]?.id ?? ``) - } - } catch (error) { - console.error('Error updating collections:', error) + const newCollections = collectionsArray() + if (!Array.isArray(newCollections)) return + if (activeCollectionId() === `` && newCollections.length > 0) { + setActiveCollectionId(newCollections[0]?.id ?? ``) } }) @@ -430,7 +366,7 @@ export const BaseTanStackDbDevtoolsPanel = try { return transactions().length } catch (error) { - console.error('Error getting transactions count:', error) + console.error(`Error getting transactions count:`, error) return 0 } }} @@ -457,7 +393,10 @@ export const BaseTanStackDbDevtoolsPanel = if (!id) return transactions() return transactionsForActiveCollection() } catch (error) { - console.error('Error getting transactions for panel:', error) + console.error( + `Error getting transactions for panel:`, + error + ) return [] } }} @@ -471,7 +410,9 @@ export const BaseTanStackDbDevtoolsPanel =
- + v + 1) }) onCleanup(() => { - unsubscribe?.() + unsubscribe() }) }) @@ -63,7 +74,7 @@ export function CollectionDetailsPanel({ ) } - if (!registry || !registry.store || !registry.store.transactions || !metadata) { + if (!registry || !metadata) { return createLiveQueryCollection({ __devtoolsInternal: true, id: `__devtools_view_transactions_for_collection_empty_local`, @@ -81,8 +92,10 @@ export function CollectionDetailsPanel({ query: (q: any) => q .from({ transactions: registry.store.transactions }) - .where(({ transactions }: any) => eq(transactions.collectionId, metadata.id)) - .orderBy(({ transactions }: any) => transactions.createdAt, 'desc') + .where(({ transactions }: any) => + eq(transactions.collectionId, metadata.id) + ) + .orderBy(({ transactions }: any) => transactions.createdAt, `desc`) .select(({ transactions }: any) => ({ id: transactions.id, collectionId: transactions.collectionId, @@ -103,9 +116,9 @@ export function CollectionDetailsPanel({ }) const activeTransaction = createMemo(() => { - const transactions = collectionTransactions() + const txs = collectionTransactions() const selectedId = selectedTransaction() - return transactions.find((t) => t.id === selectedId) + return txs.find((t) => t.id === selectedId) }) const tabs: Array<{ id: CollectionTab; label: string }> = [ @@ -232,10 +245,15 @@ export function CollectionDetailsPanel({ )} > {tab.label} - {tab.id === `transactions` && Array.isArray(transactionsForCollectionQuery.data) && - (transactionsForCollectionQuery.data as any[]).length > 0 && ( + {tab.id === `transactions` && + Array.isArray(transactionsForCollectionQuery.data) && + (transactionsForCollectionQuery.data as Array).length > + 0 && ( - {(transactionsForCollectionQuery.data as any[]).length} + { + (transactionsForCollectionQuery.data as Array) + .length + } )} diff --git a/packages/db-devtools/src/devtools-store.ts b/packages/db-devtools/src/devtools-store.ts index ad4e469e1..29460ca5f 100644 --- a/packages/db-devtools/src/devtools-store.ts +++ b/packages/db-devtools/src/devtools-store.ts @@ -1,17 +1,17 @@ import { createCollection, localOnlyCollectionOptions } from "@tanstack/db" import type { CollectionImpl } from "../../db/src/collection" import type { Transaction } from "../../db/src/transactions" -import type { - DevtoolsCollectionEntry, - DevtoolsTransactionEntry, +import type { + CollectionMetadata, + DevtoolsCollectionEntry, DevtoolsStore, - CollectionMetadata + DevtoolsTransactionEntry, } from "./types" // Collections collection - stores devtools collection entries const devtoolsCollectionsCollection = createCollection( localOnlyCollectionOptions({ - id: '__devtools_collections', + id: `__devtools_collections`, __devtoolsInternal: true, // Prevent self-registration getKey: (entry: DevtoolsCollectionEntry) => entry.id, }) @@ -20,7 +20,7 @@ const devtoolsCollectionsCollection = createCollection( // Transactions collection - stores devtools transaction entries const devtoolsTransactionsCollection = createCollection( localOnlyCollectionOptions({ - id: '__devtools_transactions', + id: `__devtools_transactions`, __devtoolsInternal: true, // Prevent self-registration getKey: (entry: DevtoolsTransactionEntry) => entry.id, }) @@ -45,7 +45,9 @@ class DevtoolsStoreImpl implements DevtoolsStore { return false } - registerCollection = (collection: CollectionImpl): (() => void) | undefined => { + registerCollection = ( + collection: CollectionImpl + ): (() => void) | undefined => { // Check if collection is already registered const existingEntry = this.collections.get(collection.id) if (existingEntry) { @@ -115,7 +117,10 @@ class DevtoolsStoreImpl implements DevtoolsStore { } } - registerTransaction = (transaction: Transaction, collectionId: string): void => { + registerTransaction = ( + transaction: Transaction, + collectionId: string + ): void => { // Check if transaction is already registered const existingEntry = this.transactions.get(transaction.id) if (existingEntry) { @@ -206,7 +211,9 @@ class DevtoolsStoreImpl implements DevtoolsStore { return results } - getTransactions = (collectionId?: string): Array => { + getTransactions = ( + collectionId?: string + ): Array => { const transactions: Array = [] for (const entry of this.transactions.values()) { @@ -285,7 +292,7 @@ class DevtoolsStoreImpl implements DevtoolsStore { garbageCollect = (): void => { // Remove entries for collections that have been garbage collected - const collectionsToRemove: string[] = [] + const collectionsToRemove: Array = [] for (const entry of this.collections.values()) { const collection = entry.weakRef.deref() if (!collection) { @@ -299,7 +306,7 @@ class DevtoolsStoreImpl implements DevtoolsStore { } // Remove entries for transactions that have been garbage collected - const transactionsToRemove: string[] = [] + const transactionsToRemove: Array = [] for (const entry of this.transactions.values()) { const transaction = entry.weakRef.deref() if (!transaction) { diff --git a/packages/db-devtools/src/devtools.ts b/packages/db-devtools/src/devtools.ts index 1248e424f..2d790801d 100644 --- a/packages/db-devtools/src/devtools.ts +++ b/packages/db-devtools/src/devtools.ts @@ -16,9 +16,11 @@ export function initializeDbDevtools(): void { } // Check if devtools are already initialized - if (getDevtools()) { - return - } + const hasGlobal = Object.prototype.hasOwnProperty.call( + window, + `__TANSTACK_DB_DEVTOOLS__` + ) + if (hasGlobal) return // Initialize the registry and store const registry = initializeDevtoolsRegistry() @@ -56,7 +58,7 @@ export function registerCollection( const devtools = getDevtools() if (devtools?.registerCollection && collection) { const updateCallback = devtools.registerCollection(collection) - if (updateCallback && collection) { + if (updateCallback) { ;(collection as any).__devtoolsUpdateCallback = updateCallback } } diff --git a/packages/db-devtools/src/global-types.ts b/packages/db-devtools/src/global-types.ts index 852321deb..d0fa4e5dd 100644 --- a/packages/db-devtools/src/global-types.ts +++ b/packages/db-devtools/src/global-types.ts @@ -17,7 +17,7 @@ declare global { } // Export the type for use in other files -export type TanStackDbDevtools = NonNullable +export type TanStackDbDevtools = NonNullable // Helper function for accessing devtools with proper typing export function getDevtools(): TanStackDbDevtools | undefined { diff --git a/packages/db-devtools/src/index.ts b/packages/db-devtools/src/index.ts index 8aee0b830..0768420a1 100644 --- a/packages/db-devtools/src/index.ts +++ b/packages/db-devtools/src/index.ts @@ -7,7 +7,10 @@ export * from "./types" // Re-export components export { BaseTanStackDbDevtoolsPanel } from "./BaseTanStackDbDevtoolsPanel" -export { TanstackDbDevtools, type TanstackDbDevtoolsConfig } from "./TanstackDbDevtools" +export { + TanstackDbDevtools, + type TanstackDbDevtoolsConfig, +} from "./TanstackDbDevtools" export { FloatingTanStackDbDevtools } from "./FloatingTanStackDbDevtools" // Re-export utilities diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index b4655f56b..951083961 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -1,11 +1,11 @@ import { createSignal } from "solid-js" +import { initializeDevtoolsStore } from "./devtools-store" +import { getDevtools } from "./global-types" import type { CollectionMetadata, DbDevtoolsRegistry, TransactionDetails, } from "./types" -import { initializeDevtoolsStore } from "./devtools-store" -import { getDevtools } from "./global-types" class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { public store = initializeDevtoolsStore() @@ -44,7 +44,9 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { const updatedMetadata = this.getCollectionMetadata(id) if (!updatedMetadata) return const currentCollections = this._collectionsSignal[0]() - const next = currentCollections.map((c) => (c.id === id ? updatedMetadata : c)) + const next = currentCollections.map((c) => + c.id === id ? updatedMetadata : c + ) this._collectionsSignal[1](next) } @@ -56,12 +58,12 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { registerCollection = (collection: any): (() => void) | undefined => { const updateCallback = this.store.registerCollection(collection) - + // Set the update callback on the collection for future updates if (updateCallback && collection) { - ;(collection as any).__devtoolsUpdateCallback = updateCallback + collection.__devtoolsUpdateCallback = updateCallback } - + // Trigger reactive update for immediate UI refresh this.triggerUpdate() @@ -113,9 +115,9 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { getTransactions = (collectionId?: string): Array => { const devtoolsTransactions = this.store.getTransactions(collectionId) - + // Convert DevtoolsTransactionEntry to TransactionDetails for backward compatibility - return devtoolsTransactions.map(entry => ({ + return devtoolsTransactions.map((entry) => ({ id: entry.id, collectionId: entry.collectionId, state: entry.state, @@ -191,5 +193,5 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { if (!getDevtools()) { window.__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() as any } - return (getDevtools() as unknown) as DbDevtoolsRegistry + return getDevtools() as unknown as DbDevtoolsRegistry } diff --git a/packages/db-devtools/src/types.ts b/packages/db-devtools/src/types.ts index 938710437..2191cbfa0 100644 --- a/packages/db-devtools/src/types.ts +++ b/packages/db-devtools/src/types.ts @@ -1,6 +1,5 @@ -import type { CollectionImpl } from "../../db/src/collection" +import type { Collection, CollectionImpl } from "../../db/src/collection" import type { CollectionStatus } from "../../db/src/types" -import type { Collection } from "../../db/src/collection" export interface DbDevtoolsConfig { /** @@ -105,24 +104,26 @@ export interface DevtoolsTransactionEntry { export interface DevtoolsStore { collections: Collection transactions: Collection - + // Registration methods - registerCollection: (collection: CollectionImpl) => (() => void) | undefined + registerCollection: ( + collection: CollectionImpl + ) => (() => void) | undefined unregisterCollection: (id: string) => void registerTransaction: (transaction: any, collectionId: string) => void - + // Access methods getCollection: (id: string) => CollectionImpl | undefined releaseCollection: (id: string) => void - + // Metadata access getAllCollectionMetadata: () => Array getTransactions: (collectionId?: string) => Array - + // Update methods updateCollection: (id: string) => void updateTransactions: (collectionId?: string) => void - + // Utility methods cleanup: () => void garbageCollect: () => void diff --git a/packages/db/src/devtools-globals.d.ts b/packages/db/src/devtools-globals.d.ts new file mode 100644 index 000000000..3eacb336c --- /dev/null +++ b/packages/db/src/devtools-globals.d.ts @@ -0,0 +1,25 @@ +// Ambient declaration to provide the global devtools type inside the core db package +// This avoids depending on the db-devtools package while allowing typed access + +export {} + +declare global { + interface Window { + // Minimal subset needed by the core db package + __TANSTACK_DB_DEVTOOLS__?: { + registerCollection?: (collection: any) => (() => void) | undefined + unregisterCollection?: (id: string) => void + registerTransaction?: (transaction: any, collectionId: string) => void + updateTransactions?: (collectionId?: string) => void + store?: { + registerTransaction?: (transaction: any, collectionId: string) => void + } + } + + // Queue used before devtools initialize (read/written by core) + __TANSTACK_DB_PENDING_TRANSACTIONS__?: Array<{ + transaction: any + collectionId: string + }> + } +} diff --git a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx index 967b56f4a..23608dc27 100644 --- a/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx +++ b/packages/react-db-devtools/src/ReactDbDevtoolsPanel.tsx @@ -71,7 +71,7 @@ export const TanStackReactDbDevtoolsPanel: React.FC< // Create a SolidJS component that renders the base panel const SolidComponent = () => { // Filter out React-specific props that might not be compatible - const { children, ...solidProps } = rest as any + const { children: _children, ...solidProps } = rest as any return BaseTanStackDbDevtoolsPanel({ registry: () => registry, style: () => ({ From 8ca5fbd872b64887f7ff74b4e90f678dad962712 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 13:38:42 +0100 Subject: [PATCH 10/18] checkpint --- packages/db-devtools/src/registry.ts | 58 ++++++++++++---------------- packages/db-devtools/src/types.ts | 4 -- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index 951083961..058bbb22f 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -36,8 +36,18 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { this._collectionsSignal[1]([...collectionsData]) // Update transactions signal - const transactionsData = this.getTransactions() - this._transactionsSignal[1](transactionsData) + const transactionsData = this.store.getTransactions() + this._transactionsSignal[1]( + transactionsData.map((entry) => ({ + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + isPersisted: entry.isPersisted, + })) + ) } private triggerCollectionUpdate = (id: string) => { @@ -52,8 +62,18 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { private triggerTransactionUpdate = (collectionId?: string) => { // Get updated transactions data - const updatedTransactions = this.getTransactions(collectionId) - this._transactionsSignal[1](updatedTransactions) + const updatedTransactions = this.store.getTransactions(collectionId) + this._transactionsSignal[1]( + updatedTransactions.map((entry) => ({ + id: entry.id, + collectionId: entry.collectionId, + state: entry.state, + mutations: entry.mutations, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + isPersisted: entry.isPersisted, + })) + ) } registerCollection = (collection: any): (() => void) | undefined => { @@ -113,36 +133,6 @@ class DbDevtoolsRegistryImpl implements DbDevtoolsRegistry { this.store.releaseCollection(id) } - getTransactions = (collectionId?: string): Array => { - const devtoolsTransactions = this.store.getTransactions(collectionId) - - // Convert DevtoolsTransactionEntry to TransactionDetails for backward compatibility - return devtoolsTransactions.map((entry) => ({ - id: entry.id, - collectionId: entry.collectionId, - state: entry.state, - mutations: entry.mutations, - createdAt: entry.createdAt, - updatedAt: entry.updatedAt, - isPersisted: entry.isPersisted, - })) - } - - getTransaction = (id: string): TransactionDetails | undefined => { - const entry = this.store.transactions.get(id) - if (!entry) return undefined - - return { - id: entry.id, - collectionId: entry.collectionId, - state: entry.state, - mutations: entry.mutations, - createdAt: entry.createdAt, - updatedAt: entry.updatedAt, - isPersisted: entry.isPersisted, - } - } - cleanup = (): void => { this.store.cleanup() } diff --git a/packages/db-devtools/src/types.ts b/packages/db-devtools/src/types.ts index 2191cbfa0..acfd54ca5 100644 --- a/packages/db-devtools/src/types.ts +++ b/packages/db-devtools/src/types.ts @@ -183,10 +183,6 @@ export interface DbDevtoolsRegistry { getCollection: (id: string) => CollectionImpl | undefined releaseCollection: (id: string) => void - // Transaction access - getTransactions: (collectionId?: string) => Array - getTransaction: (id: string) => TransactionDetails | undefined - // Cleanup utilities cleanup: () => void garbageCollect: () => void From 432f23317794f7fcd9d83a9466edc01c1d78cce5 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 13:55:19 +0100 Subject: [PATCH 11/18] fix lint --- packages/db-devtools/src/registry.ts | 7 +---- packages/db/src/collection.ts | 42 ++++------------------------ packages/db/src/proxy.ts | 4 +-- 3 files changed, 8 insertions(+), 45 deletions(-) diff --git a/packages/db-devtools/src/registry.ts b/packages/db-devtools/src/registry.ts index 058bbb22f..8553c6853 100644 --- a/packages/db-devtools/src/registry.ts +++ b/packages/db-devtools/src/registry.ts @@ -169,11 +169,6 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { updateCollectionMetadata: () => {}, updateTransactions: () => {}, getTransactions: () => [], - getTransaction: () => undefined, - getTransactionDetails: () => undefined, - clearTransactionHistory: () => {}, - onTransactionStart: () => {}, - onTransactionEnd: () => {}, cleanup: () => {}, garbageCollect: () => {}, } as DbDevtoolsRegistry @@ -181,7 +176,7 @@ export function initializeDevtoolsRegistry(): DbDevtoolsRegistry { // Only create real signals on the client side if (!getDevtools()) { - window.__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() as any + window.__TANSTACK_DB_DEVTOOLS__ = createDbDevtoolsRegistry() } return getDevtools() as unknown as DbDevtoolsRegistry } diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 103fb661d..d1ab37301 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -78,7 +78,7 @@ function registerWithDevtools(collection: CollectionImpl): void { const devtools = window.__TANSTACK_DB_DEVTOOLS__ as any if (devtools?.registerCollection) { const updateCallback = devtools.registerCollection(collection) - if (updateCallback && collection) { + if (updateCallback) { ;(collection as any).__devtoolsUpdateCallback = updateCallback ;(collection as any).isRegisteredWithDevtools = true } else { @@ -736,7 +736,7 @@ export class CollectionImpl< typeof window !== `undefined` && window.__TANSTACK_DB_DEVTOOLS__?.unregisterCollection ) { - window.__TANSTACK_DB_DEVTOOLS__!.unregisterCollection(this.id) + window.__TANSTACK_DB_DEVTOOLS__.unregisterCollection(this.id) this.isRegisteredWithDevtools = false } @@ -1430,42 +1430,10 @@ export class CollectionImpl< return } - // Register transaction with devtools (don't delete from devtools) + // Register transaction with devtools if (typeof window !== `undefined`) { - const w: any = window as any - const devtools = window.__TANSTACK_DB_DEVTOOLS__ as any - try { - if (devtools?.store?.registerTransaction) { - devtools.store.registerTransaction(transaction, this.id) - console.log(`[db->devtools] registerTransaction`, { - collectionId: this.id, - txId: transaction.id, - state: transaction.state, - }) - } else if (devtools?.registerTransaction) { - // Back-compat if global exposes method at root - devtools.registerTransaction(transaction, this.id) - console.log(`[db->devtools] registerTransaction (legacy)`, { - collectionId: this.id, - txId: transaction.id, - state: transaction.state, - }) - } else { - // Devtools not ready yet – queue for later flush - w.__TANSTACK_DB_PENDING_TRANSACTIONS__ = - w.__TANSTACK_DB_PENDING_TRANSACTIONS__ || [] - w.__TANSTACK_DB_PENDING_TRANSACTIONS__.push({ - transaction, - collectionId: this.id, - }) - console.log(`[db->devtools] queued transaction`, { - collectionId: this.id, - txId: transaction.id, - }) - } - } catch (e) { - console.warn(`[db->devtools] registerTransaction failed`, e) - } + const devtools = window.__TANSTACK_DB_DEVTOOLS__ + devtools?.store?.registerTransaction?.(transaction, this.id) } // Only schedule cleanup for transactions that aren't already completed diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index f93e9d865..253245164 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -182,8 +182,8 @@ function deepEqual( // memoizing object pairs that have already been compared const objA = a as unknown as object const objB = b as unknown as object - const isObjA = typeof a === `object` && a !== null - const isObjB = typeof b === `object` && b !== null + const isObjA = typeof a === `object` + const isObjB = typeof b === `object` if (isObjA && isObjB) { const seenForA = visited.get(objA) From a12609f148711439b32f7ec4d71d8ff60a625451 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Thu, 14 Aug 2025 14:24:07 +0100 Subject: [PATCH 12/18] tab to show the query IR for live queries --- .../src/components/CollectionDetailsPanel.tsx | 91 +++++++++++++++++-- packages/db/src/query/compiler/index.ts | 4 + .../db/src/query/live-query-collection.ts | 7 ++ packages/db/src/types.ts | 8 ++ 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx index 23f715cdb..1619bb32a 100644 --- a/packages/db-devtools/src/components/CollectionDetailsPanel.tsx +++ b/packages/db-devtools/src/components/CollectionDetailsPanel.tsx @@ -25,13 +25,24 @@ export interface CollectionDetailsPanelProps { activeCollection: Accessor } -type CollectionTab = `summary` | `config` | `state` | `transactions` | `data` +type CollectionTab = `summary` | `config` | `state` | `transactions` | `data` | `query-ir` export function CollectionDetailsPanel({ activeCollection, }: CollectionDetailsPanelProps) { const styles = useStyles() const [selectedTab, setSelectedTab] = createSignal(`summary`) + + // Reset selected tab if it's not available for the current collection + createEffect(() => { + const currentCollection = collection() + const availableTabs = tabs() + const currentTab = selectedTab() + + if (currentCollection && !availableTabs.find(tab => tab.id === currentTab)) { + setSelectedTab(`summary`) + } + }) const [selectedTransaction, setSelectedTransaction] = createSignal< string | null >(null) @@ -121,13 +132,23 @@ export function CollectionDetailsPanel({ return txs.find((t) => t.id === selectedId) }) - const tabs: Array<{ id: CollectionTab; label: string }> = [ - { id: `summary`, label: `Summary` }, - { id: `config`, label: `Config` }, - { id: `state`, label: `State` }, - { id: `transactions`, label: `Transactions` }, - { id: `data`, label: `Data` }, - ] + const tabs = createMemo(() => { + const currentCollection = collection() + const baseTabs: Array<{ id: CollectionTab; label: string }> = [ + { id: `summary`, label: `Summary` }, + { id: `config`, label: `Config` }, + { id: `state`, label: `State` }, + { id: `transactions`, label: `Transactions` }, + { id: `data`, label: `Data` }, + ] + + // Only add Query IR tab for live query collections + if (currentCollection?.metadata.type === `live-query`) { + baseTabs.push({ id: `query-ir`, label: `Query IR` }) + } + + return baseTabs + }) const renderTabContent = () => { const currentCollection = collection() @@ -150,7 +171,13 @@ export function CollectionDetailsPanel({ return instance ? ( instance.config} + value={() => { + // Filter out devtools internal properties + const config = { ...instance.config } + delete config.__devtoolsInternal + delete config.__devtoolsQueryIR + return config + }} defaultExpanded={{}} /> ) : ( @@ -215,6 +242,50 @@ export function CollectionDetailsPanel({ ) } + case `query-ir`: { + const currentCollection = collection() + if (!currentCollection?.instance) { + return ( +
+ Collection instance not available +
+ ) + } + + // Only show for live query collections + if (currentCollection.metadata.type !== `live-query`) { + return ( +
+ Query IR is only available for live query collections +
+ ) + } + + const queryIR = currentCollection.instance.config.__devtoolsQueryIR + if (!queryIR) { + return ( +
+ Query IR not available for this collection +
+ ) + } + + return ( +
+ queryIR.unoptimized} + defaultExpanded={{}} + /> + queryIR.optimized} + defaultExpanded={{}} + /> +
+ ) + } + default: return null } @@ -236,7 +307,7 @@ export function CollectionDetailsPanel({
{collectionMetadata().id}
- {tabs.map((tab) => ( + {tabs().map((tab) => (