Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(stash): clean up mutations, emit updates as a list #3376

Merged
merged 18 commits into from
Jan 6, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/popular-lies-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/stash": patch
---

Consolidated how state changes are applied and subscribers notified. Stash subscribers now receive an ordered list of state updates rather than an object.
91 changes: 91 additions & 0 deletions packages/stash/src/actions/applyUpdates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { schemaAbiTypeToDefaultValue } from "@latticexyz/schema-type/internal";
import { Key, Stash, TableRecord, TableUpdates } from "../common";
import { encodeKey } from "./encodeKey";
import { Table } from "@latticexyz/config";
import { registerTable } from "./registerTable";

export type PendingStashUpdate<table extends Table = Table> = {
table: table;
key: Key<table>;
value: undefined | Partial<TableRecord<table>>;
};

export type ApplyUpdatesArgs = {
stash: Stash;
updates: PendingStashUpdate[];
};

type PendingUpdates = {
[namespaceLabel: string]: {
[tableLabel: string]: TableUpdates;
};
};

const pendingStashUpdates = new Map<Stash, PendingUpdates>();

export function applyUpdates({ stash, updates }: ApplyUpdatesArgs): void {
const pendingUpdates = pendingStashUpdates.get(stash) ?? {};
if (!pendingStashUpdates.has(stash)) pendingStashUpdates.set(stash, pendingUpdates);

for (const { table, key, value } of updates) {
if (stash.get().config[table.namespaceLabel]?.[table.label] == null) {
registerTable({ stash, table });
}
const tableState = ((stash._.state.records[table.namespaceLabel] ??= {})[table.label] ??= {});
const encodedKey = encodeKey({ table, key });
const prevRecord = tableState[encodedKey];

// create new record, preserving field order
const nextRecord =
value == null
? undefined
: Object.fromEntries(
Object.entries(table.schema).map(([fieldName, { type }]) => [
fieldName,
key[fieldName] ?? // Use provided key fields
value[fieldName] ?? // Or provided value fields
prevRecord?.[fieldName] ?? // Keep existing non-overridden fields
schemaAbiTypeToDefaultValue[type], // Default values for new fields
]),
);

// apply update to state
if (nextRecord != null) {
tableState[encodedKey] = nextRecord;
} else {
delete tableState[encodedKey];
}

// add update to pending updates for notifying subscribers
const tableUpdates = ((pendingUpdates[table.namespaceLabel] ??= {})[table.label] ??= []);
tableUpdates.push({
table,
key,
previous: prevRecord,
current: nextRecord,
});
}

queueMicrotask(() => {
notifySubscribers(stash);
});
Comment on lines +69 to +71
Copy link
Member

@holic holic Jan 3, 2025

Choose a reason for hiding this comment

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

in my entrykit branch, I had removed queuing because it caused something in the live records sync to fail and fallback to the slower sync methods and was unclear why

Suggested change
queueMicrotask(() => {
notifySubscribers(stash);
});
// TODO: wrap in queueMicrotask here once we figure out why it affects sync fallback
notifySubscribers(stash);

do you know if this is an issue here?

Copy link
Member

Choose a reason for hiding this comment

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

tested locally with a fresh template and everything seems fine

making a mental note to come back to this if we see weird issues with syncing, esp large chunks of data

}

function notifySubscribers(stash: Stash) {
const pendingUpdates = pendingStashUpdates.get(stash);
if (!pendingUpdates) return;

// Notify table subscribers
for (const [namespaceLabel, namespaceUpdates] of Object.entries(pendingUpdates)) {
for (const [tableLabel, tableUpdates] of Object.entries(namespaceUpdates)) {
stash._.tableSubscribers[namespaceLabel]?.[tableLabel]?.forEach((subscriber) => subscriber(tableUpdates));
}
}
// Notify stash subscribers
const updates = Object.values(pendingUpdates)
.map((namespaceUpdates) => Object.values(namespaceUpdates))
.flat(2);
stash._.storeSubscribers.forEach((subscriber) => subscriber({ type: "records", updates }));

pendingStashUpdates.delete(stash);
}
26 changes: 2 additions & 24 deletions packages/stash/src/actions/deleteRecord.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Table } from "@latticexyz/config";
import { Key, Stash } from "../common";
import { encodeKey } from "./encodeKey";
import { registerTable } from "./registerTable";
import { applyUpdates } from "./applyUpdates";

export type DeleteRecordArgs<table extends Table = Table> = {
stash: Stash;
Expand All @@ -12,26 +11,5 @@ export type DeleteRecordArgs<table extends Table = Table> = {
export type DeleteRecordResult = void;

export function deleteRecord<table extends Table>({ stash, table, key }: DeleteRecordArgs<table>): DeleteRecordResult {
const { namespaceLabel, label } = table;

if (stash.get().config[namespaceLabel] == null) {
registerTable({ stash, table });
}

const encodedKey = encodeKey({ table, key });
const prevRecord = stash.get().records[namespaceLabel]?.[label]?.[encodedKey];

// Early return if this record doesn't exist
if (prevRecord == null) return;

// Delete record
delete stash._.state.records[namespaceLabel]?.[label]?.[encodedKey];

// Notify table subscribers
const updates = { [encodedKey]: { prev: prevRecord && { ...prevRecord }, current: undefined } };
stash._.tableSubscribers[namespaceLabel]?.[label]?.forEach((subscriber) => subscriber(updates));

// Notify stash subscribers
const storeUpdate = { config: {}, records: { [namespaceLabel]: { [label]: updates } } };
stash._.storeSubscribers.forEach((subscriber) => subscriber(storeUpdate));
applyUpdates({ stash, updates: [{ table, key, value: undefined }] });
}
26 changes: 18 additions & 8 deletions packages/stash/src/actions/getTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ describe("getTable", () => {

describe("subscribe", () => {
it("should notify subscriber of table change", () => {
vi.useFakeTimers({ toFake: ["queueMicrotask"] });

const config1 = defineTable({
label: "table1",
schema: { a: "address", b: "uint256", c: "uint32" },
Expand All @@ -315,28 +317,36 @@ describe("getTable", () => {
table1.subscribe({ subscriber });

table1.setRecord({ key: { a: "0x00" }, value: { b: 1n, c: 2 } });
vi.advanceTimersToNextTimer();

expect(subscriber).toHaveBeenCalledTimes(1);
expect(subscriber).toHaveBeenNthCalledWith(1, {
"0x00": {
prev: undefined,
expect(subscriber).toHaveBeenNthCalledWith(1, [
{
table: config1,
key: { a: "0x00" },
previous: undefined,
current: { a: "0x00", b: 1n, c: 2 },
},
});
]);

// Expect unrelated updates to not notify subscribers
table2.setRecord({ key: { a: "0x01" }, value: { b: 1n, c: 2 } });
vi.advanceTimersToNextTimer();

expect(subscriber).toHaveBeenCalledTimes(1);

table1.setRecord({ key: { a: "0x00" }, value: { b: 1n, c: 3 } });
vi.advanceTimersToNextTimer();

expect(subscriber).toHaveBeenCalledTimes(2);
expect(subscriber).toHaveBeenNthCalledWith(2, {
"0x00": {
prev: { a: "0x00", b: 1n, c: 2 },
expect(subscriber).toHaveBeenNthCalledWith(2, [
{
table: config1,
key: { a: "0x00" },
previous: { a: "0x00", b: 1n, c: 2 },
current: { a: "0x00", b: 1n, c: 3 },
},
});
]);
});
});
});
2 changes: 1 addition & 1 deletion packages/stash/src/actions/getTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function getTable<table extends Table>({ stash, table }: GetTableArgs<tab
getRecords: (args?: TableBoundGetRecordsArgs<table>) => getRecords({ stash, table, ...args }),
setRecord: (args: TableBoundSetRecordArgs<table>) => setRecord({ stash, table, ...args }),
setRecords: (args: TableBoundSetRecordsArgs<table>) => setRecords({ stash, table, ...args }),
subscribe: (args: TableBoundSubscribeTableArgs) => subscribeTable({ stash, table, ...args }),
subscribe: (args: TableBoundSubscribeTableArgs<table>) => subscribeTable({ stash, table, ...args }),

// TODO: dynamically add setters and getters for individual fields of the table
};
Expand Down
8 changes: 3 additions & 5 deletions packages/stash/src/actions/registerTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,9 @@ export function registerTable<table extends Table>({
(stash._.tableSubscribers[namespaceLabel] ??= {})[label] ??= new Set();

// Notify stash subscribers
const storeUpdate = {
config: { [namespaceLabel]: { [label]: { prev: undefined, current: tableConfig } } },
records: {},
};
stash._.storeSubscribers.forEach((subscriber) => subscriber(storeUpdate));
stash._.storeSubscribers.forEach((subscriber) =>
subscriber({ type: "config", updates: [{ previous: undefined, current: tableConfig }] }),
);

return getTable({ stash, table });
}
13 changes: 3 additions & 10 deletions packages/stash/src/actions/setRecord.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Key, TableRecord, Stash } from "../common";
import { setRecords } from "./setRecords";
import { Table } from "@latticexyz/config";
import { Key, TableRecord, Stash } from "../common";
import { applyUpdates } from "./applyUpdates";

export type SetRecordArgs<table extends Table = Table> = {
stash: Stash;
Expand All @@ -12,12 +12,5 @@ export type SetRecordArgs<table extends Table = Table> = {
export type SetRecordResult = void;

export function setRecord<table extends Table>({ stash, table, key, value }: SetRecordArgs<table>): SetRecordResult {
setRecords({
stash,
table,
records: [
// Stored record should include key
{ ...value, ...key },
],
});
applyUpdates({ stash, updates: [{ table, key, value }] });
}
49 changes: 11 additions & 38 deletions packages/stash/src/actions/setRecords.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { dynamicAbiTypeToDefaultValue, staticAbiTypeToDefaultValue } from "@latticexyz/schema-type/internal";
import { Stash, TableRecord, TableUpdates } from "../common";
import { encodeKey } from "./encodeKey";
import { Table } from "@latticexyz/config";
import { registerTable } from "./registerTable";
import { getKey, getValue } from "@latticexyz/protocol-parser/internal";
import { Stash, TableRecord } from "../common";
import { applyUpdates } from "./applyUpdates";

export type SetRecordsArgs<table extends Table = Table> = {
stash: Stash;
Expand All @@ -13,38 +12,12 @@ export type SetRecordsArgs<table extends Table = Table> = {
export type SetRecordsResult = void;

export function setRecords<table extends Table>({ stash, table, records }: SetRecordsArgs<table>): SetRecordsResult {
const { namespaceLabel, label, schema } = table;

if (stash.get().config[namespaceLabel]?.[label] == null) {
registerTable({ stash, table });
}

// Construct table updates
const updates: TableUpdates = {};
for (const record of records) {
const encodedKey = encodeKey({ table, key: record as never });
const prevRecord = stash.get().records[namespaceLabel]?.[label]?.[encodedKey];
const newRecord = Object.fromEntries(
Object.keys(schema).map((fieldName) => [
fieldName,
record[fieldName] ?? // Override provided record fields
prevRecord?.[fieldName] ?? // Keep existing non-overridden fields
staticAbiTypeToDefaultValue[schema[fieldName] as never] ?? // Default values for new fields
dynamicAbiTypeToDefaultValue[schema[fieldName] as never],
]),
);
updates[encodedKey] = { prev: prevRecord, current: newRecord };
}

// Update records
for (const [encodedKey, { current }] of Object.entries(updates)) {
((stash._.state.records[namespaceLabel] ??= {})[label] ??= {})[encodedKey] = current as never;
}

// Notify table subscribers
stash._.tableSubscribers[namespaceLabel]?.[label]?.forEach((subscriber) => subscriber(updates));

// Notify stash subscribers
const storeUpdate = { config: {}, records: { [namespaceLabel]: { [label]: updates } } };
stash._.storeSubscribers.forEach((subscriber) => subscriber(storeUpdate));
applyUpdates({
stash,
updates: Object.values(records).map((record) => ({
table,
key: getKey(table, record),
value: getValue(table, record),
})),
});
}
Loading
Loading