Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/bindings-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
Expand All @@ -200,6 +207,9 @@
"react": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
Expand Down
82 changes: 82 additions & 0 deletions crates/bindings-typescript/src/solid-js/SpacetimeDBProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
DbConnectionBuilder,
type DbConnectionImpl,
} from '../sdk/db_connection_impl';
import { createSignal, createEffect, onCleanup, createMemo } from 'solid-js';
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<any>,
> {
connectionBuilder: DbConnectionBuilder<DbConnection>;
children?: any;
}

export function SpacetimeDBProvider<
DbConnection extends DbConnectionImpl<any>,
>(props: SpacetimeDBProviderProps<DbConnection>) {
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] =
createSignal<ManagerConnectionState>(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<DbConnection>(key());

const contextValue = createMemo<ConnectionState>(() => ({
...state(),
getConnection,
}));

// retain / release lifecycle
createEffect(() => {
ConnectionManager.retain(key(), props.connectionBuilder);

onCleanup(() => {
ConnectionManager.release(key());
});
});


return SpacetimeDBContext.Provider({
value: contextValue(),
children: props.children,
});
}
6 changes: 6 additions & 0 deletions crates/bindings-typescript/src/solid-js/connection_state.ts
Original file line number Diff line number Diff line change
@@ -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<any> | null;
};
4 changes: 4 additions & 0 deletions crates/bindings-typescript/src/solid-js/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './SpacetimeDBProvider';
export { useSpacetimeDB } from './useSpacetimeDB';
export { useTable } from './useTable';
export { useReducer } from './useReducer';
54 changes: 54 additions & 0 deletions crates/bindings-typescript/src/solid-js/useReducer.ts
Original file line number Diff line number Diff line change
@@ -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 extends UntypedReducerDef>(
reducerDef: ReducerDef
): (...params: ParamsType<ReducerDef>) => Promise<void> {
const { getConnection, isActive } = useSpacetimeDB();
const reducerName = reducerDef.accessorName;

// Queue for calls before connection is ready
const queue: {
params: ParamsType<ReducerDef>;
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<ReducerDef>
) => Promise<void>;

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<ReducerDef>) => {
const conn = getConnection();

if (!conn) {
return new Promise<void>((resolve, reject) => {
queue.push({ params, resolve, reject });
});
}

const fn = (conn.reducers as any)[reducerName] as (
...p: ParamsType<ReducerDef>
) => Promise<void>;

return fn(...params);
};
}
19 changes: 19 additions & 0 deletions crates/bindings-typescript/src/solid-js/useSpacetimeDB.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createContext, useContext } from "solid-js";
import type { ConnectionState } from "./connection_state";


export const SpacetimeDBContext = createContext<ConnectionState | undefined>(
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;
}
179 changes: 179 additions & 0 deletions crates/bindings-typescript/src/solid-js/useTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import {
createSignal,
createEffect,
onCleanup,
createMemo,
} from 'solid-js';
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<RowType> {
onInsert?: (row: RowType) => void;
onDelete?: (row: RowType) => void;
onUpdate?: (oldRow: RowType, newRow: RowType) => void;
}

type MembershipChange = 'enter' | 'leave' | 'stayIn' | 'stayOut';

function classifyMembership(
whereExpr: BooleanExpr<any> | undefined,
oldRow: Record<string, any>,
newRow: Record<string, any>
): 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<TableDef extends UntypedTableDef>(
query: Query<TableDef>,
callbacks?: UseTableCallbacks<Prettify<RowType<TableDef>>>
) {
type UseTableRowType = RowType<TableDef>;

const accessorName = getQueryAccessorName(query);
const whereExpr = getQueryWhereClause(query);
const querySql = toSql(query);

const connectionState: ConnectionState = useSpacetimeDB();

const [rows, setRows] = createSignal<

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like setRows is called anywhere except in full snapshot updates

readonly Prettify<UseTableRowType>[]
>([]);
Comment on lines +58 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be a store, updated with reconcile.


const [isReady, setIsReady] = createSignal(false);

let latestTransactionEventId: string | null = null;

const computeSnapshot = () => {
const connection = connectionState.getConnection();
if (!connection) {
setRows([]);
setIsReady(false);
return;
}

const table = connection.db[accessorName];

const result = whereExpr
? Array.from(table.iter()).filter(row =>
evaluateBooleanExpr(whereExpr, row as Record<string, any>)
)
: Array.from(table.iter());

setRows(result as Prettify<UseTableRowType>[]);
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<UntypedRemoteModule>,
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<UntypedRemoteModule>,
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<UntypedRemoteModule>,
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);
}
Loading