Skip to content

Commit b84bcc6

Browse files
authored
feat(stash): add experimental support for indices and derived tables (#3787)
1 parent e76d725 commit b84bcc6

File tree

16 files changed

+1079
-14
lines changed

16 files changed

+1079
-14
lines changed

.changeset/famous-tips-cough.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@latticexyz/stash": patch
3+
---
4+
5+
Added experimental support for indices and derived tables to Stash.
6+
7+
Derived tables are synchronously updated based on changes to source tables, enabling computed or reorganized views of existing data.
8+
9+
Indices are a special case of derived tables that mirror another table with a different key.
10+
They provide a more ergonomic API for this common pattern and are automatically considered in the `Matches` query fragment to optimize lookups on large tables.
11+
12+
Example:
13+
14+
```ts
15+
const stash = createStash();
16+
const inputTable = defineTable({
17+
label: "input",
18+
schema: { field1: "uint32", field2: "address", field3: "string" },
19+
key: ["field1"],
20+
});
21+
registerTable({ stash, table: inputTable });
22+
const indexTable = registerIndex({ stash, table: inputTable, key: ["field2", "field3"] });
23+
```

packages/stash/src/actions/applyUpdates.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import { schemaAbiTypeToDefaultValue } from "@latticexyz/schema-type/internal";
2-
import { Key, Stash, TableRecord, TableUpdates } from "../common";
2+
import { DerivedTable, PendingStashUpdate, Stash, TableUpdate, TableUpdates } from "../common";
33
import { encodeKey } from "./encodeKey";
4-
import { Table } from "@latticexyz/config";
54
import { registerTable } from "./registerTable";
65

7-
export type PendingStashUpdate<table extends Table = Table> = {
8-
table: table;
9-
key: Key<table>;
10-
value: undefined | Partial<TableRecord<table>>;
11-
};
12-
136
export type ApplyUpdatesArgs = {
147
stash: Stash;
158
updates: readonly PendingStashUpdate[];
@@ -58,12 +51,19 @@ export function applyUpdates({ stash, updates }: ApplyUpdatesArgs): void {
5851

5952
// add update to pending updates for notifying subscribers
6053
const tableUpdates = ((pendingUpdates[table.namespaceLabel] ??= {})[table.label] ??= []);
61-
tableUpdates.push({
54+
const update = {
6255
table,
6356
key,
6457
previous: prevRecord,
6558
current: nextRecord,
66-
});
59+
} satisfies TableUpdate;
60+
tableUpdates.push(update);
61+
62+
// update derived tables
63+
const derivedTables = stash._.derivedTables?.[table.namespaceLabel]?.[table.label];
64+
if (derivedTables != null) {
65+
updateDerivedTables({ stash, derivedTables, update });
66+
}
6767
}
6868

6969
queueMicrotask(() => {
@@ -89,3 +89,18 @@ function notifySubscribers(stash: Stash) {
8989

9090
pendingStashUpdates.delete(stash);
9191
}
92+
93+
type UpdateDerivedTableArgs = {
94+
stash: Stash;
95+
derivedTables: Record<string, DerivedTable>;
96+
update: TableUpdate;
97+
};
98+
99+
function updateDerivedTables({ stash, derivedTables, update }: UpdateDerivedTableArgs): void {
100+
applyUpdates({
101+
stash,
102+
updates: Object.values(derivedTables)
103+
.map(({ deriveUpdates }) => deriveUpdates(update))
104+
.flat(),
105+
});
106+
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { attest } from "@ark/attest";
2+
import { defineTable } from "@latticexyz/store/internal";
3+
import { describe, expect, it } from "vitest";
4+
import { createStash } from "../createStash";
5+
import { registerDerivedTable } from "./registerDerivedTable";
6+
import { registerTable } from "./registerTable";
7+
import { setRecord } from "./setRecord";
8+
import { deleteRecord } from "./deleteRecord";
9+
import { isDefined } from "@latticexyz/common/utils";
10+
import { getRecord } from "./getRecord";
11+
import { PendingStashUpdate, TableUpdate } from "../common";
12+
13+
describe("registerDerivedTable", () => {
14+
it("should add a new derived table to the stash", () => {
15+
const stash = createStash();
16+
const inputTable = defineTable({
17+
label: "inputTable",
18+
namespaceLabel: "namespace1",
19+
schema: { field1: "uint32", field2: "address" },
20+
key: ["field1"],
21+
});
22+
registerTable({ stash, table: inputTable });
23+
24+
const derivedTable = defineTable({
25+
label: "derivedTable",
26+
schema: { field1: "uint32", field2: "address" },
27+
key: ["field2"],
28+
});
29+
registerTable({ stash, table: derivedTable });
30+
31+
registerDerivedTable({
32+
stash,
33+
derivedTable: {
34+
input: inputTable,
35+
label: "derivedTable",
36+
deriveUpdates: (update) => {
37+
return [
38+
// Remove the previous derived record
39+
update.previous && {
40+
table: derivedTable,
41+
key: { field2: update.previous.field2 },
42+
value: undefined,
43+
},
44+
// Add the new derived record
45+
update.current && {
46+
table: derivedTable,
47+
key: { field2: update.current.field2 },
48+
value: update.current,
49+
},
50+
].filter(isDefined);
51+
},
52+
},
53+
});
54+
55+
attest(stash.get().config).snap({
56+
namespace1: {
57+
inputTable: {
58+
namespace: "namespace1",
59+
namespaceLabel: "namespace1",
60+
name: "inputTable",
61+
label: "inputTable",
62+
key: ["field1"],
63+
schema: {
64+
field1: { type: "uint32", internalType: "uint32" },
65+
field2: { type: "address", internalType: "address" },
66+
},
67+
type: "table",
68+
tableId: "0x74626e616d6573706163653100000000696e7075745461626c65000000000000",
69+
},
70+
},
71+
"": {
72+
derivedTable: {
73+
namespace: "",
74+
namespaceLabel: "",
75+
name: "derivedTable",
76+
label: "derivedTable",
77+
key: ["field2"],
78+
schema: {
79+
field1: { type: "uint32", internalType: "uint32" },
80+
field2: { type: "address", internalType: "address" },
81+
},
82+
type: "table",
83+
tableId: "0x74620000000000000000000000000000646572697665645461626c6500000000",
84+
},
85+
},
86+
});
87+
attest(stash.get().records).snap({ namespace1: { inputTable: {} }, "": { derivedTable: {} } });
88+
expect(stash._.derivedTables?.namespace1?.inputTable?.derivedTable).toBeDefined();
89+
});
90+
91+
it("should compute the initial derived table", () => {
92+
const stash = createStash();
93+
const inputTable = defineTable({
94+
label: "inputTable",
95+
namespaceLabel: "namespace1",
96+
schema: { field1: "uint32", field2: "address" },
97+
key: ["field1"],
98+
});
99+
registerTable({ stash, table: inputTable });
100+
const derivedTable = defineTable({
101+
label: "derivedTable",
102+
schema: { field1: "uint32", field2: "address" },
103+
key: ["field2"],
104+
});
105+
registerTable({ stash, table: derivedTable });
106+
setRecord({ stash, table: inputTable, key: { field1: 1 }, value: { field2: "0x123" } });
107+
registerDerivedTable({
108+
stash,
109+
derivedTable: {
110+
input: inputTable,
111+
label: "derivedTable",
112+
deriveUpdates: (update) => {
113+
return [
114+
// Remove the previous derived record
115+
update.previous && {
116+
table: derivedTable,
117+
key: { field2: update.previous.field2 },
118+
value: undefined,
119+
},
120+
// Add the new derived record
121+
update.current && {
122+
table: derivedTable,
123+
key: { field2: update.current.field2 },
124+
value: update.current,
125+
},
126+
].filter(isDefined);
127+
},
128+
},
129+
});
130+
131+
attest(stash.get().records).equals({
132+
namespace1: {
133+
inputTable: { "1": { field1: 1, field2: "0x123" } },
134+
},
135+
"": {
136+
derivedTable: { "0x123": { field1: 1, field2: "0x123" } },
137+
},
138+
});
139+
});
140+
141+
it("should update the derived table when the input table is updated", () => {
142+
const stash = createStash();
143+
const inputTable = defineTable({
144+
label: "inputTable",
145+
namespaceLabel: "namespace1",
146+
schema: { field1: "uint32", field2: "address" },
147+
key: ["field1"],
148+
});
149+
registerTable({ stash, table: inputTable });
150+
const derivedTable = defineTable({
151+
label: "derivedTable",
152+
schema: { field1: "uint32", field2: "address" },
153+
key: ["field2"],
154+
});
155+
registerTable({ stash, table: derivedTable });
156+
setRecord({ stash, table: inputTable, key: { field1: 1 }, value: { field2: "0x123" } });
157+
registerDerivedTable({
158+
stash,
159+
derivedTable: {
160+
input: inputTable,
161+
label: "derivedTable",
162+
deriveUpdates: (update) => {
163+
return [
164+
// Remove the previous derived record
165+
update.previous && {
166+
table: derivedTable,
167+
key: { field2: update.previous.field2 },
168+
value: undefined,
169+
},
170+
// Add the new derived record
171+
update.current && {
172+
table: derivedTable,
173+
key: { field2: update.current.field2 },
174+
value: update.current,
175+
},
176+
].filter(isDefined);
177+
},
178+
},
179+
});
180+
181+
// After registering the derived table, the derived table should have been hydrated with the derived state
182+
attest(stash.get().records).equals({
183+
namespace1: {
184+
inputTable: { "1": { field1: 1, field2: "0x123" } },
185+
},
186+
"": {
187+
derivedTable: { "0x123": { field1: 1, field2: "0x123" } },
188+
},
189+
});
190+
191+
// Setting a new record on the source table should update the derived table
192+
setRecord({ stash, table: inputTable, key: { field1: 2 }, value: { field2: "0x456" } });
193+
attest(stash.get().records).equals({
194+
namespace1: {
195+
inputTable: {
196+
"1": { field1: 1, field2: "0x123" },
197+
"2": { field1: 2, field2: "0x456" },
198+
},
199+
},
200+
"": {
201+
derivedTable: {
202+
"0x123": { field1: 1, field2: "0x123" },
203+
"0x456": { field1: 2, field2: "0x456" },
204+
},
205+
},
206+
});
207+
});
208+
209+
it("should handle non-unique keys", () => {
210+
const stash = createStash();
211+
const inputTable = defineTable({
212+
label: "inputTable",
213+
namespaceLabel: "namespace1",
214+
schema: { field1: "uint32", field2: "address" },
215+
key: ["field1"],
216+
});
217+
registerTable({ stash, table: inputTable });
218+
const derivedTable = defineTable({
219+
label: "derivedTable",
220+
schema: { field1: "uint32", field2: "address", index: "uint32" },
221+
key: ["field2", "index"],
222+
});
223+
registerTable({ stash, table: derivedTable });
224+
setRecord({ stash, table: inputTable, key: { field1: 1 }, value: { field2: "0x123" } });
225+
setRecord({ stash, table: inputTable, key: { field1: 2 }, value: { field2: "0x123" } });
226+
227+
registerDerivedTable({
228+
stash,
229+
derivedTable: {
230+
input: inputTable,
231+
label: "derivedTable",
232+
deriveUpdates: (() => {
233+
let count = 0;
234+
return ({ previous, current }: TableUpdate<typeof inputTable>) => {
235+
// Remove the previous derived record
236+
const updates: PendingStashUpdate[] = [];
237+
if (previous) {
238+
// Find the previous derived record
239+
for (let i = 0; i < count; i++) {
240+
const previousDerivedRecord = getRecord({
241+
stash,
242+
table: derivedTable,
243+
key: { field2: previous.field2, index: i },
244+
});
245+
if (previousDerivedRecord?.field1 === previous.field1) {
246+
// Remove the previous derived record
247+
updates.push({
248+
table: derivedTable,
249+
key: { field2: previous.field2, index: i },
250+
value: undefined,
251+
});
252+
if (i < count - 1) {
253+
// Update the index of the last derived record if it exists
254+
const lastDerivedRecord = getRecord({
255+
stash,
256+
table: derivedTable,
257+
key: { field2: previous.field2, index: count - 1 },
258+
});
259+
updates.push({
260+
table: derivedTable,
261+
key: { field2: previous.field2, index: count - 1 },
262+
value: undefined,
263+
});
264+
updates.push({
265+
table: derivedTable,
266+
key: { field2: previous.field2, index: i },
267+
value: { ...lastDerivedRecord, index: i },
268+
});
269+
}
270+
count--;
271+
break;
272+
}
273+
}
274+
}
275+
// Add the new derived record
276+
if (current) {
277+
updates.push({
278+
table: derivedTable,
279+
key: { field2: current.field2, index: count },
280+
value: { ...current, index: count },
281+
});
282+
count++;
283+
}
284+
285+
return updates;
286+
};
287+
})(),
288+
},
289+
});
290+
291+
attest(stash.get().records[""]?.derivedTable).equals({
292+
"0x123|0": { field1: 1, field2: "0x123", index: 0 },
293+
"0x123|1": { field1: 2, field2: "0x123", index: 1 },
294+
});
295+
296+
deleteRecord({ stash, table: inputTable, key: { field1: 2 } });
297+
attest(stash.get().records[""]?.derivedTable).equals({
298+
"0x123|0": { field1: 1, field2: "0x123", index: 0 },
299+
});
300+
});
301+
});

0 commit comments

Comments
 (0)