Skip to content

Commit 558c4e0

Browse files
joshua-spacetimeCentril
authored andcommitted
Make accessor required for table-level index defs in typescript (#4525)
# Description of Changes This patch contains two main changes: 1. It makes `accessor` a required argument for table-level index defs in typescript to align with rust. 2. It removes unsound index typing in the TypeScript bindings by splitting index data into two explicit representations: - `indexes`: declarative user config (`IndexOpts`) used for type inference - `resolvedIndexes`: normalized runtime metadata (`UntypedIndex`) derived from `RawTableDefV10` `TableCacheImpl` now builds index accessors from `resolvedIndexes` instead of re-casting `indexes` at runtime. This addressed the following comment: ``` // TODO: horrible horrible horrible. we smuggle this `Array<UntypedIndex>` // by casting it to an `Array<IndexOpts>` as `TableToSchema` expects. // This is then used in `TableCacheImpl.constructor` and who knows where else. // We should stop lying about our types. ``` > Why? We were conflating two different concepts under `tableDef.indexes`: 1. declared table-level index options authored by users 2. resolved runtime index definitions (including field-level inferred indexes) This required unsafe casts (`T['idxs']` / `UntypedIndex`) and made the type model unsound. Note, (2) was largely ai assisted. # API and ABI breaking changes Technically breaks the module api, although I believe this is the behavior that is outlined in the spec, and so the current behavior should really be considered a bug. # Expected complexity level and risk 2 # Testing Added unit tests covering the following: 1. Table-level explicit index without accessor throws. 2. Table-level duplicate accessor throws. 3. Table-level explicit accessor is accepted and used. 4. Field-level `.index(...)` derives accessor from the field name (`displayName`).
1 parent 273b831 commit 558c4e0

16 files changed

Lines changed: 534 additions & 76 deletions

File tree

crates/bindings-typescript/src/lib/indexes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { ColumnIsUnique } from './constraints';
99
* existing column names are referenced.
1010
*/
1111
export type IndexOpts<AllowedCol extends string> = {
12-
accessor?: string;
12+
accessor: string;
1313
name?: string;
1414
} & (
1515
| { algorithm: 'btree'; columns: readonly AllowedCol[] }

crates/bindings-typescript/src/lib/schema.ts

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,35 @@ export interface TableToSchema<
6161
accessorName: AccName;
6262
columns: T['rowType']['row'];
6363
rowType: T['rowSpacetimeType'];
64+
// Declarative user-provided table-level indexes.
6465
indexes: T['idxs'];
66+
// Resolved runtime index metadata used by runtime consumers (e.g. TableCache).
67+
resolvedIndexes: readonly UntypedIndex<keyof T['rowType']['row'] & string>[];
6568
constraints: T['constraints'];
6669
}
6770

6871
export function tablesToSchema<
6972
const T extends Record<string, UntypedTableSchema>,
7073
>(ctx: ModuleContext, tables: T): TablesToSchema<T> {
74+
// `TablesToSchema<T>['tables']` is intentionally readonly in the public type,
75+
// but we need a mutable builder while materializing it from entries.
76+
type MutableTableDefs = {
77+
-readonly [AccName in keyof TablesToSchema<T>['tables']]: TablesToSchema<T>['tables'][AccName];
78+
};
79+
const tableDefs = Object.create(null) as MutableTableDefs;
80+
for (const [accName, schema] of Object.entries(tables) as [
81+
keyof T & string,
82+
T[keyof T & string],
83+
][]) {
84+
tableDefs[accName] = tableToSchema(
85+
accName,
86+
schema,
87+
schema.tableDef(ctx, accName)
88+
) as TablesToSchema<T>['tables'][typeof accName];
89+
}
90+
7191
return {
72-
tables: Object.fromEntries(
73-
Object.entries(tables).map(([accName, schema]) => [
74-
accName,
75-
tableToSchema(accName, schema, schema.tableDef(ctx, accName)),
76-
])
77-
) as TablesToSchema<T>['tables'],
92+
tables: tableDefs as TablesToSchema<T>['tables'],
7893
};
7994
}
8095

@@ -90,6 +105,46 @@ export function tableToSchema<
90105
schema.rowType.algebraicType.value.elements[i].name;
91106

92107
type AllowedCol = keyof T['rowType']['row'] & string;
108+
// Build fully-resolved runtime index metadata from the host-facing RawTableDef.
109+
// This is intentionally separate from `schema.idxs`, which keeps the original
110+
// user-declared `IndexOpts` shape for type-level inference.
111+
const resolvedIndexes: UntypedIndex<AllowedCol>[] = tableDef.indexes.map(
112+
idx => {
113+
const accessorName = idx.accessorName;
114+
if (typeof accessorName !== 'string' || accessorName.length === 0) {
115+
throw new TypeError(
116+
`Index '${idx.sourceName ?? '<unknown>'}' on table '${tableDef.sourceName}' is missing accessor name`
117+
);
118+
}
119+
120+
const columnIds =
121+
idx.algorithm.tag === 'Direct'
122+
? [idx.algorithm.value]
123+
: idx.algorithm.value;
124+
125+
const unique = tableDef.constraints.some(
126+
c =>
127+
c.data.tag === 'Unique' &&
128+
c.data.value.columns.every(col => columnIds.includes(col))
129+
);
130+
131+
const algorithm = (
132+
{
133+
BTree: 'btree',
134+
Hash: 'hash',
135+
Direct: 'direct',
136+
} as const
137+
)[idx.algorithm.tag];
138+
139+
return {
140+
name: accessorName,
141+
unique,
142+
algorithm,
143+
columns: columnIds.map(getColName) as AllowedCol[],
144+
};
145+
}
146+
);
147+
93148
return {
94149
// For client,`schama.tableName` will always be there as canonical name.
95150
// For module, if explicit name is not provided via `name`, accessor name will
@@ -98,29 +153,16 @@ export function tableToSchema<
98153
accessorName: accName,
99154
columns: schema.rowType.row, // typed as T[i]['rowType']['row'] under TablesToSchema<T>
100155
rowType: schema.rowSpacetimeType,
156+
// Keep declarative indexes in their original shape for type-level consumers.
157+
indexes: schema.idxs,
101158
constraints: tableDef.constraints.map(c => ({
102159
name: c.sourceName,
103160
constraint: 'unique',
104161
columns: c.data.value.columns.map(getColName) as [string],
105162
})),
106-
// TODO: horrible horrible horrible. we smuggle this `Array<UntypedIndex>`
107-
// by casting it to an `Array<IndexOpts>` as `TableToSchema` expects.
108-
// This is then used in `TableCacheImpl.constructor` and who knows where else.
109-
// We should stop lying about our types.
110-
indexes: tableDef.indexes.map((idx): UntypedIndex<AllowedCol> => {
111-
const columnIds =
112-
idx.algorithm.tag === 'Direct'
113-
? [idx.algorithm.value]
114-
: idx.algorithm.value;
115-
return {
116-
name: idx.accessorName!,
117-
unique: tableDef.constraints.some(c =>
118-
c.data.value.columns.every(col => columnIds.includes(col))
119-
),
120-
algorithm: idx.algorithm.tag.toLowerCase() as 'btree',
121-
columns: columnIds.map(getColName),
122-
};
123-
}) as T['idxs'],
163+
// Expose resolved runtime indexes separately so runtime users don't have to
164+
// reinterpret `indexes` with unsafe casts.
165+
resolvedIndexes,
124166
tableDef,
125167
...(tableDef.isEvent ? { isEvent: true } : {}),
126168
};

crates/bindings-typescript/src/lib/table.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
Indexes,
1818
IndexOpts,
1919
ReadonlyIndexes,
20+
UntypedIndex,
2021
} from './indexes';
2122
import ScheduleAt from './schedule_at';
2223
import type { TableSchema } from './table_schema';
@@ -116,7 +117,25 @@ export type UntypedTableDef = {
116117
columns: Record<string, ColumnBuilder<any, any, ColumnMetadata<any>>>;
117118
// This is really just a ProductType where all the elements have names.
118119
rowType: RowBuilder<RowObj>['algebraicType']['value'];
120+
/**
121+
* Declarative multi-column indexes supplied by user code in `table({ indexes: [...] }, ...)`.
122+
*
123+
* This is intentionally the *declarative* shape (`IndexOpts`) because a lot of
124+
* type-level behavior is derived from these entries (for example query-builder
125+
* inference over composite indexes).
126+
*/
119127
indexes: readonly IndexOpts<any>[];
128+
/**
129+
* Fully-resolved runtime indexes materialized from `RawTableDefV10`.
130+
*
131+
* This contains both:
132+
* 1) field-level indexes inferred from column metadata, and
133+
* 2) explicit table-level indexes.
134+
*
135+
* Runtime consumers like `TableCacheImpl` should use this field instead of
136+
* reinterpreting `indexes` as runtime index metadata.
137+
*/
138+
resolvedIndexes: readonly UntypedIndex<any>[];
120139
constraints: readonly ConstraintOpts<any>[];
121140
tableDef: RawTableDefV10;
122141
isEvent?: boolean;
@@ -400,6 +419,14 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
400419

401420
// convert explicit multi‑column indexes coming from options.indexes
402421
for (const indexOpts of userIndexes ?? []) {
422+
const accessor = indexOpts.accessor;
423+
if (typeof accessor !== 'string' || accessor.length === 0) {
424+
const tableLabel = name ?? '<unnamed>';
425+
const indexLabel = indexOpts.name ?? '<unnamed>';
426+
throw new TypeError(
427+
`Index '${indexLabel}' on table '${tableLabel}' must define a non-empty 'accessor'`
428+
);
429+
}
403430
let algorithm: RawIndexAlgorithm;
404431
switch (indexOpts.algorithm) {
405432
case 'btree':
@@ -418,16 +445,19 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
418445
algorithm = { tag: 'Direct', value: colIds.get(indexOpts.column)! };
419446
break;
420447
}
421-
// unnamed indexes will be assigned a globally unique name
422-
// The name users supply is actually the accessor name which will be used
423-
// in TypeScript to access the index. This will be used verbatim.
424-
// This is confusing because it is not the index name and there is
425-
// no actual way for the user to set the actual index name.
426-
// I think we should standardize: name and accessorName as the way to set
427-
// the name and accessor name of an index across all SDKs.
448+
449+
// Unnamed indexes are assigned a globally unique source name.
450+
// `accessor` controls the TypeScript property used to access the index.
451+
// `name` (if present) is preserved as the canonical schema name.
452+
//
453+
// IMPORTANT: we intentionally do not reject duplicate accessor names here.
454+
// This preserves existing behavior for raw table definitions. Downstream
455+
// runtime consumers decide how duplicates are resolved:
456+
// - server runtime merges duplicate accessors onto one accessor object
457+
// - client cache assignment is last-write-wins for duplicate accessors
428458
indexes.push({
429459
sourceName: undefined,
430-
accessorName: indexOpts.accessor,
460+
accessorName: accessor,
431461
algorithm,
432462
canonicalName: indexOpts.name,
433463
});
@@ -496,7 +526,9 @@ export function table<Row extends RowObj, const Opts extends TableOpts<Row>>(
496526
isEvent,
497527
};
498528
},
499-
idxs: {} as OptsIndices<Opts>,
529+
// Preserve the declared index options as runtime data so `tableToSchema`
530+
// can expose them without type-smuggling.
531+
idxs: userIndexes as OptsIndices<Opts>,
500532
constraints: constraints as OptsConstraints<Opts>,
501533
schedule,
502534
};

crates/bindings-typescript/src/sdk/table_cache.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
ReadonlyIndexes,
1313
ReadonlyRangedIndex,
1414
ReadonlyUniqueIndex,
15-
UntypedIndex,
1615
} from '../lib/indexes.ts';
1716
import type { Bound } from '../server/range.ts';
1817
import type { Prettify } from '../lib/type_util.ts';
@@ -84,23 +83,27 @@ export class TableCacheImpl<
8483
this.tableDef = tableDef;
8584
this.rows = new Map();
8685
this.emitter = new EventEmitter();
87-
// Build indexes
88-
const indexesDef = this.tableDef.indexes || [];
89-
for (const idx of indexesDef) {
90-
// TODO: don't do this. See comment in `tableToSchema` in `schema.ts`
91-
const idxDef = idx as UntypedIndex<
92-
keyof TableDefForTableName<RemoteModule, TableName>['columns'] & string
93-
>;
86+
// Build index views from the resolved runtime index metadata.
87+
//
88+
// We intentionally use `resolvedIndexes` rather than `indexes`:
89+
// - `indexes` is declarative table-level config (`IndexOpts`) used mainly for typing.
90+
// - `resolvedIndexes` is the runtime shape (`UntypedIndex`) that includes both
91+
// field-level and explicit table-level indexes.
92+
for (const idxDef of this.tableDef.resolvedIndexes) {
9493
const index = this.#makeReadonlyIndex(this.tableDef, idxDef);
94+
// IMPORTANT: for duplicate accessor names, client cache uses assignment
95+
// semantics and later entries overwrite earlier ones. This matches prior
96+
// behavior and is intentionally different from server runtime merge logic.
9597
(this as any)[idxDef.name] = index;
9698
}
9799
}
98100

99101
// TODO: this just scans the whole table; we should build proper index structures
100102
#makeReadonlyIndex<
101-
I extends UntypedIndex<
102-
keyof TableDefForTableName<RemoteModule, TableName>['columns'] & string
103-
>,
103+
I extends TableDefForTableName<
104+
RemoteModule,
105+
TableName
106+
>['resolvedIndexes'][number],
104107
>(
105108
tableDef: TableDefForTableName<RemoteModule, TableName>,
106109
idx: I

crates/bindings-typescript/src/server/runtime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ function makeTableView(
516516
) as Table<any>;
517517

518518
for (const indexDef of table.indexes) {
519+
const accessorName = indexDef.accessorName!;
519520
const index_id = sys.index_id_from_name(indexDef.sourceName!);
520521

521522
let column_ids: number[];
@@ -795,10 +796,13 @@ function makeTableView(
795796
} as RangedIndex<any, any>;
796797
}
797798

798-
if (Object.hasOwn(tableView, indexDef.accessorName!)) {
799-
freeze(Object.assign(tableView[indexDef.accessorName!], index));
799+
// IMPORTANT: duplicate accessor handling.
800+
// When multiple raw indexes share the same accessor name, we merge index
801+
// methods onto a single accessor object instead of throwing.
802+
if (Object.hasOwn(tableView, accessorName)) {
803+
freeze(Object.assign((tableView as any)[accessorName], index));
800804
} else {
801-
tableView[indexDef.accessorName!] = freeze(index) as any;
805+
(tableView as any)[accessorName] = freeze(index);
802806
}
803807
}
804808

crates/bindings-typescript/src/server/schema.test-d.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,40 @@ spacetimedb.init(ctx => {
6060
// @ts-expect-error update() is NOT allowed on non-PK unique indexes
6161
const _update = ctx.db.person.name2.update;
6262
});
63+
64+
/**
65+
* Regression coverage for the declared-vs-resolved index split:
66+
* - declared table-level indexes must still produce typed accessors
67+
* - field-level indexes must still produce typed accessors
68+
* - non-indexed fields must not accidentally become index accessors
69+
*/
70+
const account = table(
71+
{
72+
indexes: [
73+
{
74+
accessor: 'byEmailAndOrg',
75+
algorithm: 'btree',
76+
columns: ['email', 'orgId'] as const,
77+
},
78+
] as const,
79+
},
80+
{
81+
accountId: t.u32(),
82+
email: t.string().index('hash'),
83+
orgId: t.u32(),
84+
nickname: t.string(),
85+
}
86+
);
87+
88+
const spacetimedbIndexSplit = schema({ account });
89+
90+
spacetimedbIndexSplit.init(ctx => {
91+
// Explicit table-level index accessor from `table({ indexes: [...] })`.
92+
ctx.db.account.byEmailAndOrg.filter(['a@example.com', 1]);
93+
94+
// Field-level index accessor derived from column metadata.
95+
ctx.db.account.email.filter('a@example.com');
96+
97+
// @ts-expect-error `nickname` is not indexed, so no index accessor should exist.
98+
const _nickname = ctx.db.account.nickname;
99+
});

crates/bindings-typescript/src/server/view.test-d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const person = table(
77
name: 'person',
88
indexes: [
99
{
10+
accessor: 'name_id_idx',
1011
name: 'name_id_idx',
1112
algorithm: 'btree',
1213
columns: ['name', 'id'] as const,
@@ -48,6 +49,7 @@ const order = table(
4849
name: 'order',
4950
indexes: [
5051
{
52+
accessor: 'id_person_id',
5153
name: 'id_person_id', // We are adding this to make sure `person_id` still isn't considered indexed.
5254
algorithm: 'btree',
5355
columns: ['id', 'person_id'] as const,

crates/bindings-typescript/test-app/src/module_bindings/index.ts

Lines changed: 22 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)