diff --git a/crates/bindings-typescript/package.json b/crates/bindings-typescript/package.json index 02df183da28..7da98494e8c 100644 --- a/crates/bindings-typescript/package.json +++ b/crates/bindings-typescript/package.json @@ -95,6 +95,12 @@ "import": "./dist/angular/index.mjs", "require": "./dist/angular/index.cjs", "default": "./dist/angular/index.mjs" + }, + "./solid-js": { + "types": "./dist/solid-js/index.d.ts", + "import": "./dist/solid-js/index.mjs", + "require": "./dist/solid-js/index.cjs", + "default": "./dist/solid-js/index.mjs" } }, "size-limit": [ @@ -189,6 +195,7 @@ "@angular/core": ">=17.0.0", "@tanstack/react-query": "^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", + "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "undici": "^6.19.2", "vue": "^3.3.0" @@ -200,6 +207,9 @@ "react": { "optional": true }, + "solid-js": { + "optional": true + }, "svelte": { "optional": true }, diff --git a/crates/bindings-typescript/src/solid-js/SpacetimeDBProvider.ts b/crates/bindings-typescript/src/solid-js/SpacetimeDBProvider.ts new file mode 100644 index 00000000000..a8ec25f2c66 --- /dev/null +++ b/crates/bindings-typescript/src/solid-js/SpacetimeDBProvider.ts @@ -0,0 +1,86 @@ +import { + DbConnectionBuilder, + type DbConnectionImpl, +} from '../sdk/db_connection_impl'; +import { createEffect, onCleanup, createMemo } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { SpacetimeDBContext } from './useSpacetimeDB'; +import type { ConnectionState } from './connection_state'; +import { ConnectionId } from '../lib/connection_id'; +import { + ConnectionManager, + type ConnectionState as ManagerConnectionState, +} from '../sdk/connection_manager'; + +export interface SpacetimeDBProviderProps< + DbConnection extends DbConnectionImpl, +> { + connectionBuilder: DbConnectionBuilder; + children?: any; +} + +export function SpacetimeDBProvider< + DbConnection extends DbConnectionImpl, +>(props: SpacetimeDBProviderProps) { + const uri = () => props.connectionBuilder.getUri(); + const moduleName = () => props.connectionBuilder.getModuleName(); + + const key = createMemo(() => + ConnectionManager.getKey(uri(), moduleName()) + ); + + const fallbackState: ManagerConnectionState = { + isActive: false, + identity: undefined, + token: undefined, + connectionId: ConnectionId.random(), + connectionError: undefined, + }; + + const [state, setState] = createStore(fallbackState); + + // Subscription to ConnectionManager + createEffect(() => { + const unsubscribe = ConnectionManager.subscribe(key(), () => { + const snapshot = + ConnectionManager.getSnapshot(key()) ?? fallbackState; + setState(snapshot); + }); + + // initial snapshot + const snapshot = + ConnectionManager.getSnapshot(key()) ?? fallbackState; + setState(snapshot); + + onCleanup(() => { + unsubscribe(); + }); + }); + + const getConnection = () => + ConnectionManager.getConnection(key()); + + const contextValue: ConnectionState = { + get isActive() { return state.isActive; }, + get identity() { return state.identity; }, + get token() { return state.token; }, + get connectionId() { return state.connectionId; }, + get connectionError() { return state.connectionError; }, + getConnection, + }; + + // retain / release lifecycle + createEffect(() => { + ConnectionManager.retain(key(), props.connectionBuilder); + + onCleanup(() => { + ConnectionManager.release(key()); + }); + }); + + + return SpacetimeDBContext.Provider({ + value: contextValue, + children: props.children, + }); +} \ No newline at end of file diff --git a/crates/bindings-typescript/src/solid-js/connection_state.ts b/crates/bindings-typescript/src/solid-js/connection_state.ts new file mode 100644 index 00000000000..5ad565f9be6 --- /dev/null +++ b/crates/bindings-typescript/src/solid-js/connection_state.ts @@ -0,0 +1,6 @@ +import type { DbConnectionImpl } from '../sdk/db_connection_impl'; +import type { ConnectionState as ManagerConnectionState } from '../sdk/connection_manager'; + +export type ConnectionState = ManagerConnectionState & { + getConnection(): DbConnectionImpl | null; +}; diff --git a/crates/bindings-typescript/src/solid-js/index.ts b/crates/bindings-typescript/src/solid-js/index.ts new file mode 100644 index 00000000000..390acb20f92 --- /dev/null +++ b/crates/bindings-typescript/src/solid-js/index.ts @@ -0,0 +1,4 @@ +export * from './SpacetimeDBProvider'; +export { useSpacetimeDB } from './useSpacetimeDB'; +export { useTable } from './useTable'; +export { useReducer } from './useReducer'; diff --git a/crates/bindings-typescript/src/solid-js/useReducer.ts b/crates/bindings-typescript/src/solid-js/useReducer.ts new file mode 100644 index 00000000000..683ca26dc73 --- /dev/null +++ b/crates/bindings-typescript/src/solid-js/useReducer.ts @@ -0,0 +1,54 @@ +import { createEffect } from 'solid-js'; +import type { UntypedReducerDef } from '../sdk/reducers'; +import { useSpacetimeDB } from './useSpacetimeDB'; +import type { ParamsType } from '../sdk'; + +export function useReducer( + reducerDef: ReducerDef +): (...params: ParamsType) => Promise { + const { getConnection, isActive } = useSpacetimeDB(); + const reducerName = reducerDef.accessorName; + + // Queue for calls before connection is ready + const queue: { + params: ParamsType; + resolve: () => void; + reject: (err: unknown) => void; + }[] = []; + + // Flush queue when connection becomes available + createEffect(() => { + if (!isActive) return; + + const conn = getConnection(); + if (!conn) return; + + const fn = (conn.reducers as any)[reducerName] as ( + ...p: ParamsType + ) => Promise; + + if (queue.length) { + const pending = queue.splice(0); + for (const item of pending) { + fn(...item.params).then(item.resolve, item.reject); + } + } + }); + + // Returned reducer caller + return (...params: ParamsType) => { + const conn = getConnection(); + + if (!conn) { + return new Promise((resolve, reject) => { + queue.push({ params, resolve, reject }); + }); + } + + const fn = (conn.reducers as any)[reducerName] as ( + ...p: ParamsType + ) => Promise; + + return fn(...params); + }; +} \ No newline at end of file diff --git a/crates/bindings-typescript/src/solid-js/useSpacetimeDB.ts b/crates/bindings-typescript/src/solid-js/useSpacetimeDB.ts new file mode 100644 index 00000000000..93b60289b13 --- /dev/null +++ b/crates/bindings-typescript/src/solid-js/useSpacetimeDB.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "solid-js"; +import type { ConnectionState } from "./connection_state"; + + +export const SpacetimeDBContext = createContext( + undefined +); + +export function useSpacetimeDB(): ConnectionState { + const context = useContext(SpacetimeDBContext) as ConnectionState | undefined; + + if (!context) { + throw new Error( + "useSpacetimeDB must be used within a SpacetimeDBProvider component. Did you forget to add a `SpacetimeDBProvider` to your component tree?" + ); + } + + return context; +} \ No newline at end of file diff --git a/crates/bindings-typescript/src/solid-js/useTable.ts b/crates/bindings-typescript/src/solid-js/useTable.ts new file mode 100644 index 00000000000..e7aebac9115 --- /dev/null +++ b/crates/bindings-typescript/src/solid-js/useTable.ts @@ -0,0 +1,178 @@ +import { + createSignal, + createEffect, + onCleanup, + createMemo, +} from 'solid-js'; +import { createStore, reconcile } from 'solid-js/store'; +import { useSpacetimeDB } from './useSpacetimeDB'; +import { type EventContextInterface } from '../sdk/db_connection_impl'; +import type { ConnectionState } from './connection_state'; +import type { UntypedRemoteModule } from '../sdk/spacetime_module'; +import type { RowType, UntypedTableDef } from '../lib/table'; +import type { Prettify } from '../lib/type_util'; +import { + type Query, + toSql, + type BooleanExpr, + evaluateBooleanExpr, + getQueryAccessorName, + getQueryWhereClause, +} from '../lib/query'; + +export interface UseTableCallbacks { + onInsert?: (row: RowType) => void; + onDelete?: (row: RowType) => void; + onUpdate?: (oldRow: RowType, newRow: RowType) => void; +} + +type MembershipChange = 'enter' | 'leave' | 'stayIn' | 'stayOut'; + +function classifyMembership( + whereExpr: BooleanExpr | undefined, + oldRow: Record, + newRow: Record +): MembershipChange { + if (!whereExpr) return 'stayIn'; + + const oldIn = evaluateBooleanExpr(whereExpr, oldRow); + const newIn = evaluateBooleanExpr(whereExpr, newRow); + + if (oldIn && !newIn) return 'leave'; + if (!oldIn && newIn) return 'enter'; + if (oldIn && newIn) return 'stayIn'; + return 'stayOut'; +} + +export function useTable( + query: Query, + callbacks?: UseTableCallbacks>> +) { + type UseTableRowType = RowType; + + const accessorName = getQueryAccessorName(query); + const whereExpr = getQueryWhereClause(query); + const querySql = toSql(query); + + const connectionState: ConnectionState = useSpacetimeDB(); + + const [rows, setRows] = createStore[]>([]); + + const [isReady, setIsReady] = createSignal(false); + + let latestTransactionEventId: string | null = null; + + const computeSnapshot = () => { + const connection = connectionState.getConnection(); + if (!connection) { + setRows(reconcile([])); + setIsReady(false); + return; + } + + const table = connection.db[accessorName]; + + const result = whereExpr + ? Array.from(table.iter()).filter(row => + evaluateBooleanExpr(whereExpr, row as Record) + ) + : Array.from(table.iter()); + + setRows(reconcile(result as Prettify[])); + setIsReady(true); + }; + + // Subscription Effect (runs reactively) + createEffect(() => { + const connection = connectionState.getConnection(); + if (!connectionState.isActive || !connection) return; + + const cancel = connection + .subscriptionBuilder() + .onApplied(() => { + setIsReady(true); + }) + .subscribe(querySql); + + onCleanup(() => { + cancel.unsubscribe(); + }); + }); + + // Table event bindings + createEffect(() => { + const connection = connectionState.getConnection(); + if (!connection) return; + + const table = connection.db[accessorName]; + + const onInsert = ( + ctx: EventContextInterface, + row: any + ) => { + if (whereExpr && !evaluateBooleanExpr(whereExpr, row)) return; + + callbacks?.onInsert?.(row); + + if (ctx.event.id !== latestTransactionEventId) { + latestTransactionEventId = ctx.event.id; + computeSnapshot(); + } + }; + + const onDelete = ( + ctx: EventContextInterface, + row: any + ) => { + if (whereExpr && !evaluateBooleanExpr(whereExpr, row)) return; + + callbacks?.onDelete?.(row); + + if (ctx.event.id !== latestTransactionEventId) { + latestTransactionEventId = ctx.event.id; + computeSnapshot(); + } + }; + + const onUpdate = ( + ctx: EventContextInterface, + oldRow: any, + newRow: any + ) => { + const change = classifyMembership(whereExpr, oldRow, newRow); + + switch (change) { + case 'leave': + callbacks?.onDelete?.(oldRow); + break; + case 'enter': + callbacks?.onInsert?.(newRow); + break; + case 'stayIn': + callbacks?.onUpdate?.(oldRow, newRow); + break; + case 'stayOut': + return; + } + + if (ctx.event.id !== latestTransactionEventId) { + latestTransactionEventId = ctx.event.id; + computeSnapshot(); + } + }; + + table.onInsert(onInsert); + table.onDelete(onDelete); + table.onUpdate?.(onUpdate); + + computeSnapshot(); // initial load + + onCleanup(() => { + table.removeOnInsert(onInsert); + table.removeOnDelete(onDelete); + table.removeOnUpdate?.(onUpdate); + }); + }); + + return createMemo(() => [rows, isReady()] as const); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fc62ac21a7..fa0d5b0704a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: safe-stable-stringify: specifier: ^2.5.0 version: 2.5.0 + solid-js: + specifier: ^1.0.0 + version: 1.9.11 statuses: specifier: ^2.0.2 version: 2.0.2 @@ -12845,6 +12848,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + sort-css-media-queries@2.2.0: resolution: {integrity: sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==} engines: {node: '>= 6.3.0'} @@ -30792,6 +30798,12 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + sort-css-media-queries@2.2.0: {} source-map-js@1.2.1: {}