Skip to content
Open
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
17 changes: 17 additions & 0 deletions crates/bindings-typescript/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false

[*.md]
max_line_length = off
trim_trailing_whitespace = false
20 changes: 17 additions & 3 deletions crates/bindings-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@
"import": "./dist/svelte/index.mjs",
"require": "./dist/svelte/index.cjs",
"default": "./dist/svelte/index.mjs"
},
"./angular": {
"types": "./dist/angular/index.d.ts",
"import": "./dist/angular/index.mjs",
"require": "./dist/angular/index.cjs",
"default": "./dist/angular/index.mjs"
}
},
"size-limit": [
Expand Down Expand Up @@ -172,10 +178,12 @@
"url-polyfill": "^1.1.14"
},
"peerDependencies": {
"@angular/common": "^21.1.1",
"@angular/core": "^21.1.1",
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0",
"vue": "^3.3.0",
"svelte": "^4.0.0 || ^5.0.0",
"undici": "^6.19.2"
"undici": "^6.19.2",
"vue": "^3.3.0"
},
"peerDependenciesMeta": {
"react": {
Expand All @@ -189,11 +197,16 @@
},
"vue": {
"optional": true
},
"@angular/common": {
"optional": true
},
"@angular/core": {
"optional": true
}
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"svelte": "^5.0.0",
"@size-limit/file": "^11.2.0",
"@types/fast-text-encoding": "^1.0.3",
"@types/react": "^19.1.13",
Expand All @@ -205,6 +218,7 @@
"eslint": "^9.33.0",
"globals": "^15.14.0",
"size-limit": "^11.2.0",
"svelte": "^5.0.0",
"ts-node": "^10.9.2",
"tsup": "^8.1.0",
"typescript": "^5.9.3",
Expand Down
13 changes: 13 additions & 0 deletions crates/bindings-typescript/src/angular/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export * from './injectors';
export * from './providers';
export {
type Value,
type Expr,
eq,
and,
or,
isEq,
isAnd,
isOr,
where,
} from '../lib/filter';
4 changes: 4 additions & 0 deletions crates/bindings-typescript/src/angular/injectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { injectSpacetimeDB } from './inject-spacetimedb';
export { injectTable, type TableRows } from './inject-table';
export { injectSpacetimeDBConnected } from './inject-spacetimedb-connected';
export { injectReducer } from './inject-reducer';
50 changes: 50 additions & 0 deletions crates/bindings-typescript/src/angular/injectors/inject-reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ParamsType } from '../../sdk';
import type { UntypedReducerDef } from '../../sdk/reducers';
import { injectSpacetimeDB } from './inject-spacetimedb';
import { injectSpacetimeDBConnected } from './inject-spacetimedb-connected';
import { DestroyRef, effect, inject } from '@angular/core';

export function injectReducer<ReducerDef extends UntypedReducerDef>(
reducerDef: ReducerDef
) {
const conn = injectSpacetimeDB();
const isActive = injectSpacetimeDBConnected();
const destroyRef = inject(DestroyRef);

const queue: ParamsType<ReducerDef>[] = [];
const reducerName = reducerDef.accessorName;

effect(() => {
if (!isActive()) {
return;
}

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

if (queue.length) {
const pending = queue.splice(0);
for (const params of pending) {
callReducer(...params);
}
}
});

destroyRef.onDestroy(() => {
queue.splice(0);
});

return (...params: ParamsType<ReducerDef>) => {
if (!isActive()) {
queue.push(params);
return;
}

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

return callReducer(...params);
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { effect, signal, type Signal } from '@angular/core';
import { injectSpacetimeDB } from './inject-spacetimedb';

export function injectSpacetimeDBConnected(): Signal<boolean> {
const conn = injectSpacetimeDB();

const connectedSignal = signal<boolean>(conn.isActive);

// FIXME: Bit of a dirty hack for now, we need to change injectSpacetimeDB
// to return a signal so we can react to changes in connection state properly.
effect(onCleanup => {
const interval = setInterval(() => {
connectedSignal.set(conn.isActive);
}, 100);

onCleanup(() => {
clearInterval(interval);
});
});

return connectedSignal.asReadonly();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { assertInInjectionContext, inject } from '@angular/core';
import type { DbConnectionImpl } from '../../sdk';
import { SPACETIMEDB_TOKEN } from '../token';

export function injectSpacetimeDB<T extends DbConnectionImpl<any>>(): T {
assertInInjectionContext(injectSpacetimeDB);
const spacetimedb = inject(SPACETIMEDB_TOKEN);
return spacetimedb as T;
}
212 changes: 212 additions & 0 deletions crates/bindings-typescript/src/angular/injectors/inject-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { type Signal, signal, effect } from '@angular/core';
import type { RowType, UntypedTableDef } from '../../lib/table';
import type { Prettify } from '../../lib/type_util';
import { injectSpacetimeDB } from './inject-spacetimedb';
import {
type Expr,
type ColumnsFromRow,
evaluate,
toString,
classifyMembership,
} from '../../lib/filter';
import type { EventContextInterface } from '../../sdk';
import type { UntypedRemoteModule } from '../../sdk/spacetime_module';

export type RowTypeDef<TableDef extends UntypedTableDef> = Prettify<
RowType<TableDef>
>;

export interface TableRows<TableDef extends UntypedTableDef> {
rows: readonly RowTypeDef<TableDef>[];
isLoading: boolean;
error?: Error;
}

export interface InjectTableCallbacks<RowType> {
onInsert?: (row: RowType) => void;
onDelete?: (row: RowType) => void;
onUpdate?: (oldRow: RowType, newRow: RowType) => void;
}

export interface InjectTableOptions<TableDef extends UntypedTableDef> {
where?: Expr<ColumnsFromRow<RowType<TableDef>>>;
callbacks?: InjectTableCallbacks<RowTypeDef<TableDef>>;
}

/**
* Angular injection function to subscribe to a table in SpacetimeDB and receive live updates.
*
* This function returns a signal containing the table's rows, filtered by an optional `where` clause,
* and provides a loading state until the initial subscription is applied. It also allows you to specify
* callbacks for row insertions, deletions, and updates.
*
* @template TableDef The table definition type.
*
* @param tableDef - The table definition to subscribe to.
* @param options - Optional configuration including where clause and callbacks.
*
* @returns A signal containing the current rows and loading state.
*
* @example
* ```typescript
* export class UsersComponent {
* users = injectTable(User, {
* where: where(eq('isActive', true)),
* callbacks: {
* onInsert: (row) => console.log('Inserted:', row),
* onDelete: (row) => console.log('Deleted:', row),
* onUpdate: (oldRow, newRow) => console.log('Updated:', oldRow, newRow),
* }
* });
*
* // In template: {{ users().rows.length }} users
* // Loading state: {{ users().isLoading }}
* }
* ```
*/
export function injectTable<TableDef extends UntypedTableDef>(
tableDef: TableDef,
options?: InjectTableOptions<TableDef>
): Signal<TableRows<TableDef>> {
type UseTableRowType = RowType<TableDef>;

const conn = injectSpacetimeDB();

const tableName = tableDef.name;
const accessorName = tableDef.accessorName;
const whereClause = options?.where;
const callbacks = options?.callbacks;

const tableSignal = signal<TableRows<TableDef>>({
isLoading: true,
rows: [],
});

let latestTransactionEvent: any = null;
let subscribeApplied = false;

const whereKey = whereClause ? toString(tableDef, whereClause) : '';
const query =
`SELECT * FROM ${tableName}` + (whereClause ? ` WHERE ${whereKey}` : '');

// Note: this code is mostly derived from the React useTable implementation
// in order to keep behavior consistent across frameworks.

const computeSnapshot = (): readonly RowTypeDef<TableDef>[] => {
if (!conn.isActive) {
return [];
}

const table = conn.db[accessorName];

if (whereClause) {
return Array.from(table.iter()).filter(row =>
evaluate(whereClause, row as UseTableRowType)
) as RowTypeDef<TableDef>[];
}

return Array.from(table.iter()) as RowTypeDef<TableDef>[];
};

const updateSnapshot = () => {
tableSignal.set({
rows: computeSnapshot(),
isLoading: !subscribeApplied,
});
};

effect(onCleanup => {
if (!conn.isActive) {
return;
}

const table = conn.db[accessorName];

const onInsert = (
ctx: EventContextInterface<UntypedRemoteModule>,
row: any
) => {
if (whereClause && !evaluate(whereClause, row)) {
return;
}

callbacks?.onInsert?.(row);

if (ctx.event !== latestTransactionEvent || !latestTransactionEvent) {
latestTransactionEvent = ctx.event;
updateSnapshot();
}
};

const onDelete = (
ctx: EventContextInterface<UntypedRemoteModule>,
row: any
) => {
if (whereClause && !evaluate(whereClause, row)) {
return;
}

callbacks?.onDelete?.(row);

if (ctx.event !== latestTransactionEvent || !latestTransactionEvent) {
latestTransactionEvent = ctx.event;
updateSnapshot();
}
};

const onUpdate = (
ctx: EventContextInterface<UntypedRemoteModule>,
oldRow: any,
newRow: any
) => {
const change = classifyMembership(whereClause, 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 !== latestTransactionEvent || !latestTransactionEvent) {
latestTransactionEvent = ctx.event;
updateSnapshot();
}
};

table.onInsert(onInsert);
table.onDelete(onDelete);
table.onUpdate?.(onUpdate);

const subscription = conn
.subscriptionBuilder()
.onApplied(() => {
subscribeApplied = true;
updateSnapshot();
})
.onError(err => {
tableSignal.set({
rows: [],
isLoading: false,
error: err.event,
});
})
.subscribe(query);

onCleanup(() => {
table.removeOnInsert(onInsert);
table.removeOnDelete(onDelete);
table.removeOnUpdate?.(onUpdate);
subscription.unsubscribe();
});
});

return tableSignal.asReadonly();
}
1 change: 1 addition & 0 deletions crates/bindings-typescript/src/angular/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { provideSpacetimeDB } from './provide-spacetimedb';
Loading
Loading