From a861fdf6c6a4c7c0e0e156d141d4f5eec5a44fd5 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Saffari Taheri Date: Sat, 19 Jul 2025 23:01:43 +0330 Subject: [PATCH 1/5] feat: add solidjs integration. --- packages/solid-db/README.md | 3 + packages/solid-db/package.json | 65 + packages/solid-db/src/index.ts | 9 + packages/solid-db/src/useLiveQuery.ts | 338 ++++ packages/solid-db/tests/test-setup.ts | 6 + packages/solid-db/tests/useLiveQuery.test.tsx | 1361 +++++++++++++++++ packages/solid-db/tsconfig.json | 33 + packages/solid-db/vite.config.ts | 24 + pnpm-lock.yaml | 291 +++- 9 files changed, 2077 insertions(+), 53 deletions(-) create mode 100644 packages/solid-db/README.md create mode 100644 packages/solid-db/package.json create mode 100644 packages/solid-db/src/index.ts create mode 100644 packages/solid-db/src/useLiveQuery.ts create mode 100644 packages/solid-db/tests/test-setup.ts create mode 100644 packages/solid-db/tests/useLiveQuery.test.tsx create mode 100644 packages/solid-db/tsconfig.json create mode 100644 packages/solid-db/vite.config.ts diff --git a/packages/solid-db/README.md b/packages/solid-db/README.md new file mode 100644 index 00000000..652e582e --- /dev/null +++ b/packages/solid-db/README.md @@ -0,0 +1,3 @@ +# @tanstack/solid-db + +Solidjs hooks for TanStack DB. See [TanStack/db](https://github.com/TanStack/db) for more details. diff --git a/packages/solid-db/package.json b/packages/solid-db/package.json new file mode 100644 index 00000000..e7ff0f88 --- /dev/null +++ b/packages/solid-db/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tanstack/solid-db", + "description": "Solid integration for @tanstack/db", + "version": "0.0.27", + "author": "Kyle Mathews", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/db.git", + "directory": "packages/solid-db" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "optimistic", + "solid", + "typescript" + ], + "packageManager": "pnpm@10.6.3", + "dependencies": { + "@solid-primitives/map": "^0.7.2", + "@tanstack/db": "workspace:*", + "use-sync-external-store": "^1.2.0" + }, + "devDependencies": { + "@electric-sql/client": "1.0.0", + "@solidjs/testing-library": "^0.8.10", + "@types/use-sync-external-store": "^0.0.6", + "@vitest/coverage-istanbul": "^3.0.9", + "jsdom": "^26.0.0", + "solid-js": "^1.9.7", + "vite-plugin-solid": "^2.11.7", + "vitest": "^3.0.9" + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "peerDependencies": { + "solid-js": ">=1.9.0" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest --run", + "lint": "eslint . --fix" + }, + "sideEffects": false, + "type": "module", + "types": "dist/esm/index.d.ts" +} \ No newline at end of file diff --git a/packages/solid-db/src/index.ts b/packages/solid-db/src/index.ts new file mode 100644 index 00000000..bd98349f --- /dev/null +++ b/packages/solid-db/src/index.ts @@ -0,0 +1,9 @@ +// Re-export all public APIs +export * from "./useLiveQuery" + +// Re-export everything from @tanstack/db +export * from "@tanstack/db" + +// Re-export some stuff explicitly to ensure the type & value is exported +export type { Collection } from "@tanstack/db" +export { createTransaction } from "@tanstack/db" diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts new file mode 100644 index 00000000..b090a2bb --- /dev/null +++ b/packages/solid-db/src/useLiveQuery.ts @@ -0,0 +1,338 @@ +import { + batch, + createComputed, + createMemo, + createSignal, + onCleanup, +} from "solid-js" +import { ReactiveMap } from "@solid-primitives/map" +import { CollectionImpl, createLiveQueryCollection } from "@tanstack/db" +import { createStore, reconcile } from "solid-js/store" +import type { Accessor } from "solid-js" +import type { + ChangeMessage, + Collection, + CollectionStatus, + Context, + GetResult, + InitialQueryBuilder, + LiveQueryCollectionConfig, + QueryBuilder, +} from "@tanstack/db" + +/** + * Create a live query using a query function + * @param queryFn - Query function that defines what data to fetch + * @returns Object with reactive data, state, and status information + * @example + * // Basic query with object syntax + * const todosQuery = useLiveQuery((q) => + * q.from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.completed, false)) + * .select(({ todos }) => ({ id: todos.id, text: todos.text })) + * ) + * + * @example + * // With dependencies that trigger re-execution + * const todosQuery = useLiveQuery( + * (q) => q.from({ todos: todosCollection }) + * .where(({ todos }) => gt(todos.priority, minPriority())), + * ) + * + * @example + * // Join pattern + * const personIssues = useLiveQuery((q) => + * q.from({ issues: issueCollection }) + * .join({ persons: personCollection }, ({ issues, persons }) => + * eq(issues.userId, persons.id) + * ) + * .select(({ issues, persons }) => ({ + * id: issues.id, + * title: issues.title, + * userName: persons.name + * })) + * ) + * + * @example + * // Handle loading and error states + * const todosQuery = useLiveQuery((q) => + * q.from({ todos: todoCollection }) + * ) + * + * return ( + * + * + *
Loading...
+ *
+ * + *
Error: {todosQuery.status()}
+ *
+ * + * + * {(todo) =>
  • {todo.text}
  • } + *
    + *
    + *
    + * ) + */ +// Overload 1: Accept just the query function +export function useLiveQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder +): { + state: ReactiveMap> + data: Array> + collection: Accessor, string | number, {}>> + status: Accessor + isLoading: Accessor + isReady: Accessor + isIdle: Accessor + isError: Accessor + isCleanedUp: Accessor +} + +/** + * Create a live query using configuration object + * @param config - Configuration object with query and options + * @returns Object with reactive data, state, and status information + * @example + * // Basic config object usage + * const todosQuery = useLiveQuery(() => ({ + * query: (q) => q.from({ todos: todosCollection }), + * gcTime: 60000 + * })) + * + * @example + * // With query builder and options + * const queryBuilder = new Query() + * .from({ persons: collection }) + * .where(({ persons }) => gt(persons.age, 30)) + * .select(({ persons }) => ({ id: persons.id, name: persons.name })) + * + * const personsQuery = useLiveQuery(() => ({ query: queryBuilder })) + * + * @example + * // Handle all states uniformly + * const itemsQuery = useLiveQuery(() => ({ + * query: (q) => q.from({ items: itemCollection }) + * })) + * + * return ( + * {itemsQuery.data.length} items loaded}> + * + *
    Loading...
    + *
    + * + *
    Something went wrong
    + *
    + * + *
    Preparing...
    + *
    + *
    + * ) + */ +// Overload 2: Accept config object +export function useLiveQuery( + config: Accessor> +): { + state: ReactiveMap> + data: Array> + collection: Accessor, string | number, {}>> + status: Accessor + isLoading: Accessor + isReady: Accessor + isIdle: Accessor + isError: Accessor + isCleanedUp: Accessor +} + +/** + * Subscribe to an existing live query collection + * @param liveQueryCollection - Pre-created live query collection to subscribe to + * @returns Object with reactive data, state, and status information + * @example + * // Using pre-created live query collection + * const myLiveQuery = createLiveQueryCollection((q) => + * q.from({ todos: todosCollection }).where(({ todos }) => eq(todos.active, true)) + * ) + * const todosQuery = useLiveQuery(() => myLiveQuery) + * + * @example + * // Access collection methods directly + * const existingQuery = useLiveQuery(() => existingCollection) + * + * // Use collection for mutations + * const handleToggle = (id) => { + * existingQuery.collection().update(id, draft => { draft.completed = !draft.completed }) + * } + * + * @example + * // Handle states consistently + * const sharedQuery = useLiveQuery(() => sharedCollection) + * + * return ( + * {(item) => }}> + * + *
    Loading...
    + *
    + * + *
    Error loading data
    + *
    + *
    + * ) + */ +// Overload 3: Accept pre-created live query collection +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Accessor> +): { + state: Map + data: Array + collection: Accessor> + status: Accessor + isLoading: Accessor + isReady: Accessor + isIdle: Accessor + isError: Accessor + isCleanedUp: Accessor +} + +// Implementation - use function overloads to infer the actual collection type +export function useLiveQuery( + configOrQueryOrCollection: (queryFn?: any) => any +) { + const collection = createMemo( + () => { + if (configOrQueryOrCollection.length === 1) { + return createLiveQueryCollection({ + query: configOrQueryOrCollection, + startSync: true, + }) + } + + const innerCollection = configOrQueryOrCollection() + if (innerCollection instanceof CollectionImpl) { + innerCollection.startSyncImmediate() + return innerCollection as Collection + } + + return createLiveQueryCollection({ + ...innerCollection, + startSync: true, + }) + }, + undefined, + { name: `TanstackDBCollectionMemo` } + ) + + // Reactive state that gets updated granularly through change events + const state = new ReactiveMap() + + // Reactive data array that maintains sorted order + const [data, setData] = createStore>([], { + name: `TanstackDBData`, + }) + + // Track collection status reactively + const [status, setStatus] = createSignal(collection().status, { + name: `TanstackDBStatus`, + }) + + // Helper to sync data array from collection in correct order + const syncDataFromCollection = ( + currentCollection: Collection + ) => { + setData((prev) => + reconcile(Array.from(currentCollection.values()))(prev).filter(Boolean) + ) + } + + // Track current unsubscribe function + let currentUnsubscribe: (() => void) | null = null + + createComputed( + () => { + const currentCollection = collection() + + // Update status ref whenever the effect runs + setStatus(currentCollection.status) + + // Clean up previous subscription + if (currentUnsubscribe) { + currentUnsubscribe() + } + + // Initialize state with current collection data + state.clear() + for (const [key, value] of currentCollection.entries()) { + state.set(key, value) + } + + // Initialize data array in correct order + syncDataFromCollection(currentCollection) + + // Subscribe to collection changes with granular updates + currentUnsubscribe = currentCollection.subscribeChanges( + (changes: Array>) => { + // Apply each change individually to the reactive state + batch(() => { + for (const change of changes) { + switch (change.type) { + case `insert`: + case `update`: + state.set(change.key, change.value) + break + case `delete`: + state.delete(change.key) + break + } + } + }) + + // Update the data array to maintain sorted order + syncDataFromCollection(currentCollection) + + // Update status ref on every change + setStatus(currentCollection.status) + } + ) + + // Preload collection data if not already started + if (currentCollection.status === `idle`) { + currentCollection.preload().catch(console.error) + } + + // Cleanup when effect is invalidated + onCleanup(() => { + if (currentUnsubscribe) { + currentUnsubscribe() + currentUnsubscribe = null + } + }) + }, + undefined, + { name: `TanstackDBSyncComputed` } + ) + + // Cleanup on unmount (only if we're in a component context) + onCleanup(() => { + if (currentUnsubscribe) { + currentUnsubscribe() + currentUnsubscribe = null + } + }) + + return { + state, + data, + collection, + status, + isLoading: () => status() === `loading` || status() === `initialCommit`, + isReady: () => status() === `ready`, + isIdle: () => status() === `idle`, + isError: () => status() === `error`, + isCleanedUp: () => status() === `cleaned-up`, + } +} diff --git a/packages/solid-db/tests/test-setup.ts b/packages/solid-db/tests/test-setup.ts new file mode 100644 index 00000000..9b279c46 --- /dev/null +++ b/packages/solid-db/tests/test-setup.ts @@ -0,0 +1,6 @@ +import "@testing-library/jest-dom/vitest" +import { cleanup } from "@solidjs/testing-library" +import { afterEach } from "vitest" + +// https://testing-library.com/docs/solid-testing-library/api/#cleanup +afterEach(() => cleanup()) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx new file mode 100644 index 00000000..e8f58ca3 --- /dev/null +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -0,0 +1,1361 @@ +import { describe, expect, it } from "vitest" +import { renderHook, waitFor } from "@solidjs/testing-library" +import { + Query, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, +} from "@tanstack/db" +import { createComputed, createRoot, createSignal } from "solid-js" +import { useLiveQuery } from "../src/useLiveQuery" +import { mockSyncCollectionOptions } from "../../db/tests/utls" +import type { Accessor } from "solid-js" + +type Person = { + id: string + name: string + age: number + email: string + isActive: boolean + team: string +} + +type Issue = { + id: string + title: string + description: string + userId: string +} + +const initialPersons: Array = [ + { + id: `1`, + name: `John Doe`, + age: 30, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }, + { + id: `2`, + name: `Jane Doe`, + age: 25, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }, + { + id: `3`, + name: `John Smith`, + age: 35, + email: `john.smith@example.com`, + isActive: true, + team: `team1`, + }, +] + +const initialIssues: Array = [ + { + id: `1`, + title: `Issue 1`, + description: `Issue 1 description`, + userId: `1`, + }, + { + id: `2`, + title: `Issue 2`, + description: `Issue 2 description`, + userId: `2`, + }, + { + id: `3`, + title: `Issue 3`, + description: `Issue 3 description`, + userId: `1`, + }, +] + +describe(`Query Collections`, () => { + it(`should work with basic collection and select`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const rendered = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + ) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(rendered.result.state.size).toBe(1) // Only John Smith (age 35) + }) + expect(rendered.result.data).toHaveLength(1) + + const johnSmith = rendered.result.data[0] + expect(johnSmith).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + }) + + it(`should be able to query a collection with live updates`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const rendered = renderHook(() => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + .orderBy(({ collection: c }) => c.id, `asc`) + ) + }) + + // Wait for collection to sync + await waitFor(() => { + expect(rendered.result.state.size).toBe(1) + }) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + expect(rendered.result.data.length).toBe(1) + expect(rendered.result.data[0]).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + // Insert a new person using the proper utils pattern + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `4`, + name: `Kyle Doe`, + age: 40, + email: `kyle.doe@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + await waitFor(() => { + expect(rendered.result.state.size).toBe(2) + }) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + expect(rendered.result.state.get(`4`)).toMatchObject({ + id: `4`, + name: `Kyle Doe`, + }) + + expect(rendered.result.data.length).toBe(2) + expect(rendered.result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `3`, + name: `John Smith`, + }), + expect.objectContaining({ + id: `4`, + name: `Kyle Doe`, + }), + ]) + ) + + // Update the person + collection.utils.begin() + collection.utils.write({ + type: `update`, + value: { + id: `4`, + name: `Kyle Doe 2`, + age: 40, + email: `kyle.doe@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + await waitFor(() => { + expect(rendered.result.state.size).toBe(2) + }) + expect(rendered.result.state.get(`4`)).toMatchObject({ + id: `4`, + name: `Kyle Doe 2`, + }) + + expect(rendered.result.data.length).toBe(2) + expect(rendered.result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `3`, + name: `John Smith`, + }), + expect.objectContaining({ + id: `4`, + name: `Kyle Doe 2`, + }), + ]) + ) + + // Delete the person + collection.utils.begin() + collection.utils.write({ + type: `delete`, + value: { + id: `4`, + name: `Kyle Doe 2`, + age: 40, + email: `kyle.doe@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + await waitFor(() => { + expect(rendered.result.state.size).toBe(1) + }) + expect(rendered.result.state.get(`4`)).toBeUndefined() + + expect(rendered.result.data.length).toBe(1) + expect(rendered.result.data[0]).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + }) + + it(`should join collections and return combined results with live updates`, async () => { + // Create person collection + const personCollection = createCollection( + mockSyncCollectionOptions({ + id: `person-collection-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create issue collection + const issueCollection = createCollection( + mockSyncCollectionOptions({ + id: `issue-collection-test`, + getKey: (issue: Issue) => issue.id, + initialData: initialIssues, + }) + ) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) + ) + }) + + // Wait for collections to sync + await waitFor(() => { + expect(result.state.size).toBe(3) + }) + + // Verify that we have the expected joined results + + expect(result.state.get(`[1,1]`)).toMatchObject({ + id: `1`, + name: `John Doe`, + title: `Issue 1`, + }) + + expect(result.state.get(`[2,2]`)).toMatchObject({ + id: `2`, + name: `Jane Doe`, + title: `Issue 2`, + }) + + expect(result.state.get(`[3,1]`)).toMatchObject({ + id: `3`, + name: `John Doe`, + title: `Issue 3`, + }) + + // Add a new issue for user 2 + issueCollection.utils.begin() + issueCollection.utils.write({ + type: `insert`, + value: { + id: `4`, + title: `Issue 4`, + description: `Issue 4 description`, + userId: `2`, + }, + }) + issueCollection.utils.commit() + + await waitFor(() => { + expect(result.state.size).toBe(4) + }) + expect(result.state.get(`[4,2]`)).toMatchObject({ + id: `4`, + name: `Jane Doe`, + title: `Issue 4`, + }) + + // Update an issue we're already joined with + issueCollection.utils.begin() + issueCollection.utils.write({ + type: `update`, + value: { + id: `2`, + title: `Updated Issue 2`, + description: `Issue 2 description`, + userId: `2`, + }, + }) + issueCollection.utils.commit() + + await waitFor(() => { + // The updated title should be reflected in the joined results + expect(result.state.get(`[2,2]`)).toMatchObject({ + id: `2`, + name: `Jane Doe`, + title: `Updated Issue 2`, + }) + }) + + // Delete an issue + issueCollection.utils.begin() + issueCollection.utils.write({ + type: `delete`, + value: { + id: `3`, + title: `Issue 3`, + description: `Issue 3 description`, + userId: `1`, + }, + }) + issueCollection.utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // After deletion, issue 3 should no longer have a joined result + expect(result.state.get(`[3,1]`)).toBeUndefined() + expect(result.state.size).toBe(3) + }) + + it(`should recompile query when parameters change and change results`, async () => { + return createRoot(async (dispose) => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `params-change-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const [minAge, setMinAge] = createSignal(30) + const rendered = renderHook( + (props: { minAge: Accessor }) => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, props.minAge())) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + age: c.age, + })) + ) + }, + { initialProps: [{ minAge: minAge }] } + ) + + // Wait for collection to sync + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Initially should return only people older than 30 + expect(rendered.result.state.size).toBe(1) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + + // Change the parameter to include more people + setMinAge(20) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Now should return all people as they're all older than 20 + expect(rendered.result.state.size).toBe(3) + expect(rendered.result.state.get(`1`)).toMatchObject({ + id: `1`, + name: `John Doe`, + age: 30, + }) + expect(rendered.result.state.get(`2`)).toMatchObject({ + id: `2`, + name: `Jane Doe`, + age: 25, + }) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + + // Change to exclude everyone + setMinAge(50) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should now be empty + expect(rendered.result.state.size).toBe(0) + + dispose() + }) + }) + + it(`should stop old query when parameters change`, async () => { + return createRoot(async (dispose) => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `stop-query-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const [minAge, setMinAge] = createSignal(30) + const rendered = renderHook( + (props: { minAge: Accessor }) => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, props.minAge())) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + ) + }, + { initialProps: [{ minAge }] } + ) + + // Wait for collection to sync + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Initial query should return only people older than 30 + expect(rendered.result.state.size).toBe(1) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + // Change the parameter to include more people + setMinAge(25) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Query should now return all people older than 25 + expect(rendered.result.state.size).toBe(2) + expect(rendered.result.state.get(`1`)).toMatchObject({ + id: `1`, + name: `John Doe`, + }) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + // Change to a value that excludes everyone + setMinAge(50) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Should now be empty + expect(rendered.result.state.size).toBe(0) + + dispose() + }) + }) + + it(`should be able to query a result collection with live updates`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `optimistic-changes-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Initial query + const rendered = renderHook(() => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + team: c.team, + })) + .orderBy(({ collection: c }) => c.id, `asc`) + ) + }) + + // Wait for collection to sync + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Grouped query derived from initial query + const groupedLiveQuery = renderHook(() => { + return useLiveQuery((q) => + q + .from({ queryResult: rendered.result.collection() }) + .groupBy(({ queryResult }) => queryResult.team) + .select(({ queryResult }) => ({ + team: queryResult.team, + count: count(queryResult.id), + })) + ) + }) + + // Wait for grouped query to sync + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify initial grouped results + expect(groupedLiveQuery.result.state.size).toBe(1) + const teamResult = Array.from(groupedLiveQuery.result.state.values())[0] + expect(teamResult).toMatchObject({ + team: `team1`, + count: 1, + }) + + // Insert two new users in different teams + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `5`, + name: `Sarah Jones`, + age: 32, + email: `sarah.jones@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.write({ + type: `insert`, + value: { + id: `6`, + name: `Mike Wilson`, + age: 38, + email: `mike.wilson@example.com`, + isActive: true, + team: `team2`, + }, + }) + collection.utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify the grouped results include the new team members + expect(groupedLiveQuery.result.state.size).toBe(2) + + const groupedResults = Array.from(groupedLiveQuery.result.state.values()) + const team1Result = groupedResults.find((r) => r.team === `team1`) + const team2Result = groupedResults.find((r) => r.team === `team2`) + + expect(team1Result).toMatchObject({ + team: `team1`, + count: 2, // John Smith + Sarah Jones + }) + expect(team2Result).toMatchObject({ + team: `team2`, + count: 1, // Mike Wilson + }) + }) + + it(`optimistic state is dropped after commit`, async () => { + // Track renders and states + const renderStates: Array<{ + stateSize: number + hasTempKey: boolean + hasPermKey: boolean + timestamp: number + }> = [] + + // Create person collection + const personCollection = createCollection( + mockSyncCollectionOptions({ + id: `person-collection-test-bug`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create issue collection + const issueCollection = createCollection( + mockSyncCollectionOptions({ + id: `issue-collection-test-bug`, + getKey: (issue: Issue) => issue.id, + initialData: initialIssues, + }) + ) + + // Render the hook with a query that joins persons and issues + const { result } = renderHook(() => { + const queryResult = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) + ) + + // Track each render state + createComputed(() => { + renderStates.push({ + stateSize: queryResult.state.size, + hasTempKey: queryResult.state.has(`[temp-key,1]`), + hasPermKey: queryResult.state.has(`[4,1]`), + timestamp: Date.now(), + }) + }) + + return queryResult + }) + + // Wait for collections to sync and verify initial state + await waitFor(() => { + expect(result.state.size).toBe(3) + }) + + // Reset render states array for clarity in the remaining test + renderStates.length = 0 + + // Create an optimistic action for adding issues + type AddIssueInput = { + title: string + description: string + userId: string + } + + const addIssue = createOptimisticAction({ + onMutate: (issueInput) => { + // Optimistically insert with temporary key + issueCollection.insert({ + id: `temp-key`, + title: issueInput.title, + description: issueInput.description, + userId: issueInput.userId, + }) + }, + mutationFn: async (issueInput) => { + // Simulate server persistence - in a real app, this would be an API call + await new Promise((resolve) => setTimeout(resolve, 10)) // Simulate network delay + + // After "server" responds, update the collection with permanent ID using utils + issueCollection.utils.begin() + issueCollection.utils.write({ + type: `delete`, + value: { + id: `temp-key`, + title: issueInput.title, + description: issueInput.description, + userId: issueInput.userId, + }, + }) + issueCollection.utils.write({ + type: `insert`, + value: { + id: `4`, // Use the permanent ID + title: issueInput.title, + description: issueInput.description, + userId: issueInput.userId, + }, + }) + issueCollection.utils.commit() + + return { success: true, id: `4` } + }, + }) + + // Perform optimistic insert of a new issue + const transaction = addIssue({ + title: `New Issue`, + description: `New Issue Description`, + userId: `1`, + }) + + await waitFor(() => { + // Verify optimistic state is immediately reflected + expect(result.state.size).toBe(4) + }) + expect(result.state.get(`[temp-key,1]`)).toMatchObject({ + id: `temp-key`, + name: `John Doe`, + title: `New Issue`, + }) + expect(result.state.get(`[4,1]`)).toBeUndefined() + + // Wait for the transaction to be committed + await transaction.isPersisted.promise + + await waitFor(() => { + // Wait for the permanent key to appear + expect(result.state.get(`[4,1]`)).toBeDefined() + }) + + // Check if we had any render where the temp key was removed but the permanent key wasn't added yet + const hadFlicker = renderStates.some( + (state) => !state.hasTempKey && !state.hasPermKey && state.stateSize === 3 + ) + + expect(hadFlicker).toBe(false) + + // Verify the temporary key is replaced by the permanent one + expect(result.state.size).toBe(4) + expect(result.state.get(`[temp-key,1]`)).toBeUndefined() + expect(result.state.get(`[4,1]`)).toMatchObject({ + id: `4`, + name: `John Doe`, + title: `New Issue`, + }) + }) + + it(`should accept pre-created live query collection`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `pre-created-collection-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a live query collection beforehand + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + startSync: true, + }) + + const { result } = renderHook(() => { + return useLiveQuery(() => liveQueryCollection) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.state.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.data).toHaveLength(1) + + const johnSmith = result.data[0] + expect(johnSmith).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + + // Verify that the returned collection is the same instance + expect(result.collection()).toBe(liveQueryCollection) + }) + + it(`should switch to a different pre-created live query collection when changed`, async () => { + return createRoot(async (dispose) => { + const collection1 = createCollection( + mockSyncCollectionOptions({ + id: `collection-1`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const collection2 = createCollection( + mockSyncCollectionOptions({ + id: `collection-2`, + getKey: (person: Person) => person.id, + initialData: [ + { + id: `4`, + name: `Alice Cooper`, + age: 45, + email: `alice.cooper@example.com`, + isActive: true, + team: `team3`, + }, + { + id: `5`, + name: `Bob Dylan`, + age: 50, + email: `bob.dylan@example.com`, + isActive: true, + team: `team3`, + }, + ], + }) + ) + + // Create two different live query collections + const liveQueryCollection1 = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection1 }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: true, + }) + + const liveQueryCollection2 = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection2 }) + .where(({ persons }) => gt(persons.age, 40)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: true, + }) + + const [collection, setCollection] = createSignal(liveQueryCollection1) + const rendered = renderHook( + (props: { collection: Accessor }) => { + return useLiveQuery(props.collection) + }, + { initialProps: [{ collection: collection }] } + ) + + // Wait for first collection to sync + await waitFor(() => { + expect(rendered.result.state.size).toBe(1) // Only John Smith from collection1 + }) + expect(rendered.result.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + expect(rendered.result.collection()).toBe(liveQueryCollection1) + + // Switch to the second collection + setCollection(liveQueryCollection2) + + // Wait for second collection to sync + await waitFor(() => { + expect(rendered.result.state.size).toBe(2) // Alice and Bob from collection2 + }) + expect(rendered.result.state.get(`4`)).toMatchObject({ + id: `4`, + name: `Alice Cooper`, + }) + expect(rendered.result.state.get(`5`)).toMatchObject({ + id: `5`, + name: `Bob Dylan`, + }) + expect(rendered.result.collection()).toBe(liveQueryCollection2) + + // Verify we no longer have data from the first collection + expect(rendered.result.state.get(`3`)).toBeUndefined() + + dispose() + }) + }) + + it(`should accept a config object with a pre-built QueryBuilder instance`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-config-querybuilder`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a QueryBuilder instance beforehand + const queryBuilder = new Query() + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + + const { result } = renderHook(() => { + return useLiveQuery(() => ({ query: queryBuilder })) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.state.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.data).toHaveLength(1) + + const johnSmith = result.data[0] + expect(johnSmith).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + }) + + describe(`isLoaded property`, () => { + it(`should be true initially and false after collection is ready`, async () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + + // Create a collection that doesn't start sync immediately + const collection = createCollection({ + id: `has-loaded-test`, + getKey: (person: Person) => person.id, + startSync: false, // Don't start sync immediately + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = () => { + commit() + markReady() + } + // Don't call begin/commit immediately + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const rendered = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Initially isLoading should be true + expect(rendered.result.isLoading()).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn) { + beginFn() + commitFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + // Wait for collection to become ready + await waitFor(() => { + expect(rendered.result.isLoading()).toBe(false) + }) + // Note: Data may not appear immediately due to live query evaluation timing + // The main test is that isLoading transitions from true to false + }) + + it(`should be false for pre-created collections that are already syncing`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `pre-created-has-loaded-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a live query collection that's already syncing + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: true, + }) + + // Wait a bit for the collection to start syncing + await new Promise((resolve) => setTimeout(resolve, 10)) + + const rendered = renderHook(() => { + return useLiveQuery(() => liveQueryCollection) + }) + + // For pre-created collections that are already syncing, isLoading should be true + expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.state.size).toBe(1) + }) + + it(`should update isLoading when collection status changes`, async () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + + const collection = createCollection({ + id: `status-change-has-loaded-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit }) => { + beginFn = begin + commitFn = commit + // Don't sync immediately + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const rendered = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Initially should be true + expect(rendered.result.isLoading()).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn) { + beginFn() + commitFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.isReady()).toBe(true) + + // Wait for collection to become ready + await waitFor(() => { + expect(rendered.result.isLoading()).toBe(false) + }) + expect(rendered.result.status()).toBe(`ready`) + }) + + it(`should maintain isReady state during live updates`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `live-updates-has-loaded-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + }) + + // Wait for initial load + await waitFor(() => { + expect(result.isLoading()).toBe(false) + }) + + const initialIsReady = result.isReady() + + // Perform live updates + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `4`, + name: `Kyle Doe`, + age: 40, + email: `kyle.doe@example.com`, + isActive: true, + team: `team1`, + }, + }) + collection.utils.commit() + + // Wait for update to process + await waitFor(() => { + expect(result.state.size).toBe(2) + }) + + // isReady should remain true during live updates + expect(result.isReady()).toBe(true) + expect(result.isReady()).toBe(initialIsReady) + }) + + it(`should handle isLoading with complex queries including joins`, async () => { + let personBeginFn: (() => void) | undefined + let personCommitFn: (() => void) | undefined + let issueBeginFn: (() => void) | undefined + let issueCommitFn: (() => void) | undefined + + const personCollection = createCollection({ + id: `join-has-loaded-persons`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + personBeginFn = begin + personCommitFn = () => { + commit() + markReady() + } + // Don't sync immediately + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const issueCollection = createCollection({ + id: `join-has-loaded-issues`, + getKey: (issue: Issue) => issue.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + issueBeginFn = begin + issueCommitFn = () => { + commit() + markReady() + } + // Don't sync immediately + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) + ) + }) + + // Initially should be true + expect(result.isLoading()).toBe(true) + + // Start sync for both collections + personCollection.preload() + issueCollection.preload() + + // Trigger the first commit for both collections to make them ready + if (personBeginFn && personCommitFn) { + personBeginFn() + personCommitFn() + } + if (issueBeginFn && issueCommitFn) { + issueBeginFn() + issueCommitFn() + } + + // Insert data into both collections + personCollection.insert({ + id: `1`, + name: `John Doe`, + age: 30, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + issueCollection.insert({ + id: `1`, + title: `Issue 1`, + description: `Issue 1 description`, + userId: `1`, + }) + + // Wait for both collections to sync + await waitFor(() => { + expect(result.isReady()).toBe(true) + }) + // Note: Joined data may not appear immediately due to live query evaluation timing + // The main test is that isLoading transitions from false to true + }) + + it(`should handle isLoading with parameterized queries`, async () => { + return createRoot(async (dispose) => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + + const collection = createCollection({ + id: `params-has-loaded-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = () => { + commit() + markReady() + } + // Don't sync immediately + }, + }, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) + + const [minAge, setMinAge] = createSignal(30) + const { result } = renderHook( + (props: { minAge: Accessor }) => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, props.minAge())) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + ) + }, + { initialProps: [{ minAge: minAge }] } + ) + + // Initially should be false + expect(result.isLoading()).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn) { + beginFn() + commitFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + collection.insert({ + id: `2`, + name: `Jane Doe`, + age: 25, + email: `jane.doe@example.com`, + isActive: true, + team: `team2`, + }) + + // Wait for initial load + await waitFor(() => { + expect(result.isLoading()).toBe(false) + }) + + // Change parameters + setMinAge(25) + + // isReady should remain true even when parameters change + await waitFor(() => { + expect(result.isReady()).toBe(true) + }) + // Note: Data size may not change immediately due to live query evaluation timing + // The main test is that isReady remains true when parameters change + + dispose() + }) + }) + }) +}) diff --git a/packages/solid-db/tsconfig.json b/packages/solid-db/tsconfig.json new file mode 100644 index 00000000..579b240a --- /dev/null +++ b/packages/solid-db/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "paths": { + "@tanstack/store": [ + "../store/src" + ], + "@tanstack/db": [ + "../db/src" + ] + } + }, + "include": [ + "src/**/*", + "tests", + "vite.config.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/solid-db/vite.config.ts b/packages/solid-db/vite.config.ts new file mode 100644 index 00000000..444a9e1d --- /dev/null +++ b/packages/solid-db/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, mergeConfig } from "vitest/config" +import { tanstackViteConfig } from "@tanstack/config/vite" +import solidPlugin from "vite-plugin-solid" +import packageJson from "./package.json" + +const config = defineConfig({ + plugins: [solidPlugin()], + test: { + name: packageJson.name, + dir: `./tests`, + environment: `jsdom`, + setupFiles: [`./tests/test-setup.ts`], + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + }) +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f08fb9..879c303a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-start': specifier: ^1.126.1 - version: 1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + version: 1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/trailbase-db-collection': specifier: ^0.0.3 version: link:../../../packages/trailbase-db-collection @@ -311,6 +311,43 @@ importers: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) + packages/solid-db: + dependencies: + '@solid-primitives/map': + specifier: ^0.7.2 + version: 0.7.2(solid-js@1.9.7) + '@tanstack/db': + specifier: workspace:* + version: link:../db + use-sync-external-store: + specifier: ^1.2.0 + version: 1.5.0(react@19.1.0) + devDependencies: + '@electric-sql/client': + specifier: 1.0.0 + version: 1.0.0 + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(solid-js@1.9.7) + '@types/use-sync-external-store': + specifier: ^0.0.6 + version: 0.0.6 + '@vitest/coverage-istanbul': + specifier: ^3.0.9 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.16.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + solid-js: + specifier: ^1.9.7 + version: 1.9.7 + vite-plugin-solid: + specifier: ^2.11.7 + version: 2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + vitest: + specifier: ^3.0.9 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + packages/trailbase-db-collection: dependencies: '@standard-schema/spec': @@ -418,6 +455,10 @@ packages: resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} @@ -1307,14 +1348,6 @@ packages: '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1637,6 +1670,11 @@ packages: cpu: [arm64] os: [android] + '@rollup/rollup-darwin-arm64@4.44.2': + resolution: {integrity: sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-arm64@4.45.1': resolution: {integrity: sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==} cpu: [arm64] @@ -1766,6 +1804,31 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@solid-primitives/map@0.7.2': + resolution: {integrity: sha512-sXK/rS68B4oq3XXNyLrzVhLtT1pnimmMUahd2FqhtYUuyQsCfnW058ptO1s+lWc2k8F/3zQSNVkZ2ifJjlcNbQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/trigger@1.2.2': + resolution: {integrity: sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -2631,6 +2694,16 @@ packages: babel-dead-code-elimination@1.0.10: resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} + babel-plugin-jsx-dom-expressions@0.39.8: + resolution: {integrity: sha512-/MVOIIjonylDXnrWmG23ZX82m9mtKATsVHB7zYlPfDR9Vdd/NBE48if+wv27bSkBtyO7EPMUlcUc4J63QwuACQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.6: + resolution: {integrity: sha512-HXTK9f93QxoH8dYn1M2mJdOlWgMsR88Lg/ul6QCZGkNTktjTE5HAf93YxQumHoCudLEtZrU1cFCMFOVho6GqFg==} + peerDependencies: + '@babel/core': ^7.0.0 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3932,6 +4005,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -4221,6 +4297,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4592,6 +4672,10 @@ packages: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -4660,10 +4744,6 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.0.3: - resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} - engines: {node: 20 || >=22} - minimatch@3.0.8: resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} @@ -5611,6 +5691,14 @@ packages: smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} + solid-js@1.9.7: + resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + sorted-btree@1.8.1: resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} @@ -6212,6 +6300,9 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + validate-html-nesting@1.2.3: + resolution: {integrity: sha512-kdkWdCl6eCeLlRShJKbjVOU2kFKxMF8Ghu50n+crEoyx+VKm3FxAxF9z4DCy6+bbTOqNW0+jcIYRnjoIRzigRw==} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -6239,6 +6330,16 @@ packages: peerDependencies: vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite-plugin-solid@2.11.7: + resolution: {integrity: sha512-5TgK1RnE449g0Ryxb9BXqem89RSy7fE8XGVCo+Gw84IHgPuPVP7nYNP6WBVAaY/0xw+OqfdQee+kusL0y3XYNg==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -6287,6 +6388,14 @@ packages: yaml: optional: true + vitefu@1.1.1: + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6564,7 +6673,7 @@ snapshots: '@babel/parser': 7.28.0 '@babel/template': 7.27.2 '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 convert-source-map: 2.0.0 debug: 4.4.1 gensync: 1.0.0-beta.2 @@ -6576,14 +6685,14 @@ snapshots: '@babel/generator@7.28.0': dependencies: '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -6611,14 +6720,18 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.28.1 + '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 transitivePeerDependencies: - supports-color @@ -6633,7 +6746,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@babel/helper-plugin-utils@7.27.1': {} @@ -6649,7 +6762,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 transitivePeerDependencies: - supports-color @@ -6662,7 +6775,7 @@ snapshots: '@babel/helpers@7.27.6': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@babel/parser@7.28.0': dependencies: @@ -6724,7 +6837,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@babel/traverse@7.28.0': dependencies: @@ -6733,7 +6846,7 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.0 '@babel/template': 7.27.2 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -6949,7 +7062,7 @@ snapshots: '@electric-sql/client@1.0.0': optionalDependencies: - '@rollup/rollup-darwin-arm64': 4.45.1 + '@rollup/rollup-darwin-arm64': 4.44.2 '@electric-sql/d2mini@0.1.7': dependencies: @@ -7338,12 +7451,6 @@ snapshots: '@ioredis/commands@1.2.0': {} - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7731,6 +7838,9 @@ snapshots: '@rollup/rollup-android-arm64@4.45.1': optional: true + '@rollup/rollup-darwin-arm64@4.44.2': + optional: true + '@rollup/rollup-darwin-arm64@4.45.1': optional: true @@ -7835,6 +7945,25 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@solid-primitives/map@0.7.2(solid-js@1.9.7)': + dependencies: + '@solid-primitives/trigger': 1.2.2(solid-js@1.9.7) + 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) + solid-js: 1.9.7 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.7)': + dependencies: + solid-js: 1.9.7 + + '@solidjs/testing-library@0.8.10(solid-js@1.9.7)': + dependencies: + '@testing-library/dom': 10.4.0 + solid-js: 1.9.7 + '@speed-highlight/core@1.2.7': {} '@standard-schema/spec@1.0.0': {} @@ -7956,7 +8085,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.0 '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@tanstack/router-utils': 1.121.21 babel-dead-code-elimination: 1.0.10 tiny-invariant: 1.3.3 @@ -8015,9 +8144,9 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-plugin@1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tanstack/react-start-plugin@1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: - '@tanstack/start-plugin-core': 1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(drizzle-orm@0.40.1(@types/pg@8.15.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + '@tanstack/start-plugin-core': 1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(drizzle-orm@0.40.1(@types/pg@8.15.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@vitejs/plugin-react': 4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) vite: 6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) zod: 3.25.76 @@ -8065,10 +8194,10 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@tanstack/react-start@1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tanstack/react-start@1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@tanstack/react-start-client': 1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-start-plugin': 1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + '@tanstack/react-start-plugin': 1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(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.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/react-start-server': 1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/start-server-functions-client': 1.127.8(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/start-server-functions-server': 1.127.4(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) @@ -8138,14 +8267,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.127.8(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tanstack/router-plugin@1.127.8(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0) '@babel/template': 7.27.2 '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@tanstack/router-core': 1.127.8 '@tanstack/router-generator': 1.127.8 '@tanstack/router-utils': 1.121.21 @@ -8157,6 +8286,7 @@ snapshots: optionalDependencies: '@tanstack/react-router': 1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: 6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + vite-plugin-solid: 2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) transitivePeerDependencies: - supports-color @@ -8179,7 +8309,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0) '@babel/template': 7.27.2 '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@tanstack/directive-functions-plugin': 1.124.1(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) babel-dead-code-elimination: 1.0.10 tiny-invariant: 1.3.3 @@ -8194,14 +8324,14 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-plugin-core@1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(drizzle-orm@0.40.1(@types/pg@8.15.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': + '@tanstack/start-plugin-core@1.127.8(@netlify/blobs@9.1.2)(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(drizzle-orm@0.40.1(@types/pg@8.15.4)(gel@2.1.1)(pg@8.16.3)(postgres@3.4.7))(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@tanstack/router-core': 1.127.8 '@tanstack/router-generator': 1.127.8 - '@tanstack/router-plugin': 1.127.8(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + '@tanstack/router-plugin': 1.127.8(@tanstack/react-router@1.127.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)))(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/router-utils': 1.121.21 '@tanstack/server-functions-plugin': 1.124.1(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) '@tanstack/start-server-core': 1.127.8 @@ -8350,23 +8480,23 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.7 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 '@types/body-parser@1.19.6': dependencies: @@ -9040,10 +9170,25 @@ snapshots: '@babel/core': 7.28.0 '@babel/parser': 7.28.0 '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 transitivePeerDependencies: - supports-color + babel-plugin-jsx-dom-expressions@0.39.8(@babel/core@7.28.0): + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) + '@babel/types': 7.28.1 + html-entities: 2.3.3 + parse5: 7.3.0 + validate-html-nesting: 1.2.3 + + babel-preset-solid@1.9.6(@babel/core@7.28.0): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.39.8(@babel/core@7.28.0) + balanced-match@1.0.2: {} bare-events@2.6.0: @@ -9954,7 +10099,7 @@ snapshots: eslint: 9.31.0(jiti@2.4.2) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 - minimatch: 10.0.3 + minimatch: 9.0.5 semver: 7.7.2 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 @@ -10528,6 +10673,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-entities@2.3.3: {} + html-escaper@2.0.2: {} htmlparser2@10.0.0: @@ -10791,6 +10938,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -11156,7 +11305,7 @@ snapshots: magicast@0.3.5: dependencies: '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.0 source-map-js: 1.2.1 make-dir@4.0.0: @@ -11180,6 +11329,10 @@ snapshots: meow@12.1.1: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-descriptors@1.0.3: {} merge-options@3.0.4: @@ -11223,10 +11376,6 @@ snapshots: min-indent@1.0.1: {} - minimatch@10.0.3: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimatch@3.0.8: dependencies: brace-expansion: 1.1.12 @@ -11621,7 +11770,7 @@ snapshots: 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 @@ -12297,6 +12446,21 @@ snapshots: smob@1.5.0: {} + solid-js@1.9.7: + dependencies: + csstype: 3.1.3 + seroval: 1.3.2 + seroval-plugins: 1.3.2(seroval@1.3.2) + + solid-refresh@0.6.3(solid-js@1.9.7): + dependencies: + '@babel/generator': 7.28.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/types': 7.28.1 + solid-js: 1.9.7 + transitivePeerDependencies: + - supports-color + sorted-btree@1.8.1: {} source-map-js@1.2.1: {} @@ -12914,6 +13078,8 @@ snapshots: uuid@11.1.0: {} + validate-html-nesting@1.2.3: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -12965,6 +13131,21 @@ snapshots: dependencies: vite: 6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + vite-plugin-solid@2.11.7(@testing-library/jest-dom@6.6.3)(solid-js@1.9.7)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): + dependencies: + '@babel/core': 7.28.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.6(@babel/core@7.28.0) + merge-anything: 5.1.7 + solid-js: 1.9.7 + solid-refresh: 0.6.3(solid-js@1.9.7) + vite: 6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + vitefu: 1.1.1(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + optionalDependencies: + '@testing-library/jest-dom': 6.6.3 + transitivePeerDependencies: + - supports-color + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): dependencies: debug: 4.4.1 @@ -12993,6 +13174,10 @@ snapshots: tsx: 4.20.3 yaml: 2.8.0 + vitefu@1.1.1(vite@6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)): + optionalDependencies: + vite: 6.3.5(@types/node@22.16.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.16.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 From d5a7c42061afbc7f372f43227f23f4e683d7f52c Mon Sep 17 00:00:00 2001 From: Muhammad Amin Saffari Taheri Date: Sun, 20 Jul 2025 02:35:46 +0330 Subject: [PATCH 2/5] fix: clear unsubscribe function after calling it. --- packages/solid-db/src/useLiveQuery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index b090a2bb..dd4b246c 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -262,6 +262,7 @@ export function useLiveQuery( // Clean up previous subscription if (currentUnsubscribe) { currentUnsubscribe() + currentUnsubscribe = null } // Initialize state with current collection data From 1895f20c7e466a2dbcdd80a40b8a220085114e6f Mon Sep 17 00:00:00 2001 From: Muhammad Amin Saffari Taheri Date: Sun, 20 Jul 2025 02:40:38 +0330 Subject: [PATCH 3/5] feat: remove extra clean-up. --- packages/solid-db/src/useLiveQuery.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index dd4b246c..cb35fab1 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -259,12 +259,6 @@ export function useLiveQuery( // Update status ref whenever the effect runs setStatus(currentCollection.status) - // Clean up previous subscription - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } - // Initialize state with current collection data state.clear() for (const [key, value] of currentCollection.entries()) { @@ -305,7 +299,7 @@ export function useLiveQuery( currentCollection.preload().catch(console.error) } - // Cleanup when effect is invalidated + // Cleanup when computed is invalidated onCleanup(() => { if (currentUnsubscribe) { currentUnsubscribe() @@ -317,14 +311,6 @@ export function useLiveQuery( { name: `TanstackDBSyncComputed` } ) - // Cleanup on unmount (only if we're in a component context) - onCleanup(() => { - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } - }) - return { state, data, From d90cbf38109cb208f0de3afa9a2eb74a28ae1b40 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Saffari Taheri Date: Sun, 20 Jul 2025 02:55:00 +0330 Subject: [PATCH 4/5] feat: add change log. --- packages/solid-db/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/solid-db/CHANGELOG.md diff --git a/packages/solid-db/CHANGELOG.md b/packages/solid-db/CHANGELOG.md new file mode 100644 index 00000000..a11eed72 --- /dev/null +++ b/packages/solid-db/CHANGELOG.md @@ -0,0 +1,5 @@ +# @tanstack/react-db + +## 0.0.27 + +- Add support for solid-js From 52afb9c5164a4ea0ec08e1cb31a501f0df959be6 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Saffari Taheri Date: Sun, 20 Jul 2025 03:07:59 +0330 Subject: [PATCH 5/5] feat: use createResource to enable suspence and error boundary usage for initial sync. --- packages/solid-db/src/useLiveQuery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index cb35fab1..1bfaeab3 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -2,6 +2,7 @@ import { batch, createComputed, createMemo, + createResource, createSignal, onCleanup, } from "solid-js" @@ -296,7 +297,7 @@ export function useLiveQuery( // Preload collection data if not already started if (currentCollection.status === `idle`) { - currentCollection.preload().catch(console.error) + createResource(() => currentCollection.preload()) } // Cleanup when computed is invalidated